Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bf3910bdc0 test(workspaces): keep launch fixture alive 2026-08-15 12:30:17 +00:00
Mohamed Boudra b607bed49c test(workspaces): isolate runtime observation routing 2026-08-15 11:25:15 +00:00
Mohamed Boudra ec12c5c35c fix(workspaces): resolve runtime Git observation command 2026-08-15 11:01:11 +00:00
Mohamed Boudra 1daccc4414 test(workspaces): isolate runtime observation routing 2026-08-15 10:25:55 +00:00
Mohamed Boudra af140997a3 fix(workspaces): invalidate watches after helper writes 2026-08-15 10:03:14 +00:00
Mohamed Boudra 98bf569448 test(workspaces): focus runtime observation isolation 2026-08-15 09:35:32 +00:00
Mohamed Boudra bdfa718c83 test(workspaces): budget command runtime observation 2026-08-15 09:13:03 +00:00
Mohamed Boudra 791939081b fix(workspaces): await Git watcher teardown 2026-08-15 08:48:58 +00:00
Mohamed Boudra 18ec41670f test(workspaces): bound runtime Git integration waits 2026-08-15 08:19:28 +00:00
Mohamed Boudra add858f3e8 test(workspaces): isolate command runtime Git contracts 2026-08-15 07:54:55 +00:00
Mohamed Boudra 92a1e3fa66 fix(workspaces): release runtime resources on shutdown 2026-08-15 07:22:01 +00:00
Mohamed Boudra 91f76c9494 test(workspaces): isolate host Git observer seam 2026-08-15 00:51:17 +02:00
Mohamed Boudra d58230404b test(workspaces): expect helper protocol framing 2026-08-15 00:40:51 +02:00
Mohamed Boudra 692d4777c6 style(workspaces): format Windows helper test 2026-08-15 00:34:42 +02:00
Mohamed Boudra 3a8824ed99 fix(workspaces): keep helper executable standalone 2026-08-15 00:31:22 +02:00
Mohamed Boudra 6d16961d58 fix(workspaces): resolve Windows runtime commands 2026-08-15 00:24:34 +02:00
Mohamed Boudra 16e0967408 test(workspaces): make Git isolation deterministic 2026-08-14 23:58:52 +02:00
Mohamed Boudra 697a0002d9 test(workspaces): isolate sibling Git fanout 2026-08-14 23:43:21 +02:00
Mohamed Boudra 1518eb09a9 test(workspaces): isolate Git watcher readiness 2026-08-14 23:26:28 +02:00
Mohamed Boudra 3175a5ff10 chore: merge main into workspace runtime branch 2026-08-14 23:10:24 +02:00
Mohamed Boudra eafa5f2b5c fix(workspaces): resolve runtime Git portably 2026-08-14 22:54:59 +02:00
Mohamed Boudra 12c1a68956 test(workspaces): await runtime teardown 2026-08-14 22:23:22 +02:00
Mohamed Boudra cd354158f5 test(desktop): combine dev prerequisite build states 2026-08-14 21:59:24 +02:00
Mohamed Boudra cf4d702a8e fix(workspaces): release archived runtime backing 2026-08-14 21:44:48 +02:00
Mohamed Boudra e5f3b3dc02 fix(workspaces): preserve portable runtime readiness 2026-08-14 20:13:04 +02:00
Mohamed Boudra b500809229 test: assert worktree backing release 2026-08-14 19:31:04 +02:00
Mohamed Boudra 642c303859 test: remove provider descriptor clock race 2026-08-14 19:12:40 +02:00
Mohamed Boudra a67381795b test: include plugin in isolated build fixtures 2026-08-14 19:01:33 +02:00
Mohamed Boudra 8a37f2f5d3 fix: update Nix dependency hash 2026-08-14 18:49:40 +02:00
Mohamed Boudra 54f0e636b9 Merge remote-tracking branch 'origin/main' into workspace-runtime-poc
# Conflicts:
#	nix/npm-deps.hash
#	package.json
#	packages/client/src/daemon-client.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/websocket-server.ts
2026-08-14 18:43:58 +02:00
Mohamed Boudra cd429339a0 fix(workspaces): preserve runtime lifecycle boundaries 2026-08-14 18:35:07 +02:00
Mohamed Boudra 51193e7a92 fix(workspaces): stabilize runtime CI paths 2026-08-14 16:49:05 +02:00
Mohamed Boudra d35c10a8b5 test(workspaces): invoke npm portably on Windows 2026-08-14 14:34:04 +02:00
Mohamed Boudra ff83ca4bdc fix(workspaces): restore clean runtime test paths 2026-08-14 14:11:12 +02:00
Mohamed Boudra accd2d7587 fix(build): update Nix dependency hash 2026-08-14 13:44:12 +02:00
Mohamed Boudra 2501a46f3e fix(build): restore package lock metadata 2026-08-14 13:39:02 +02:00
Mohamed Boudra e2ac23da70 chore(merge): integrate main into workspace runtimes 2026-08-14 13:28:32 +02:00
Mohamed Boudra 7c32fcda98 refactor(workspaces): finalize runtime package boundaries
Keep the daemon dependent only on the public runtime contract and helper, while optional runtime implementations remain external command registrations. Workspace setup continues asynchronously through the bound runtime without coupling production packages to an implementation.
2026-08-14 12:48:35 +02:00
Mohamed Boudra 36d8464068 refactor(workspaces): make Docker a generic runtime
The server build, typecheck, and dry-run package pass with the Docker implementation deleted from the tree. paseo-workspace-helper remains a mandatory generic runtime contract requirement.
2026-08-13 21:59:13 +02:00
Mohamed Boudra cbb8295ceb fix(workspaces): unblock desktop runtime lifecycle
Electron-hosted Local helpers bypassed the sanitized self-Node launch policy, so they lacked ELECTRON_RUN_AS_NODE and provider probes hung.

Command-runtime adapters lacked exact detached POSIX process-group ownership, so forced lifecycle cleanup could leave adapter descendants alive.
2026-08-13 00:46:16 +02:00
Mohamed Boudra 33afa7283b feat(workspaces): define project runtime source authority
Route probe and user materialization through one projectRuntimeSource seam, where persisted Git source overrides the host-only convenience root and must belong to Git project data.

Lock default revision, explicit revision, and subdirectory semantics with real Docker acceptance. Enforce provider-probe internals structurally and document that the current Docker POC needs a locally built image because its default reference is unpublished.
2026-08-12 20:53:05 +02:00
Mohamed Boudra 527b252497 feat(workspaces): reconcile provider probe lifecycle
Keep lifecycle policy in provider-probe while WorkspaceRuntimeService remains the sole lifecycle executor. Idle probes pause without discarding runtime state; source and runtime fingerprints recreate probes and rotate provider bindings, while restart and project removal converge persisted records.

Close provider snapshot and helper bindings before lifecycle transitions to avoid leaks. Keep scriptTerminal in the exact bound-runtime surface because reusable local script terminals were intentionally introduced in the preceding accepted runtime slice.
2026-08-12 19:35:26 +02:00
Mohamed Boudra d03e4298ab feat(workspaces): accept Docker runtime journeys
Exercise probe, creation, terminals, file observation, Git, scripts, and agent edits through the Desktop Docker runtime while proving host decoys remain untouched.

Selected-runtime creation has a longer request budget because materialization and setup can legitimately exceed the legacy RPC ceiling; legacy creation keeps its existing timeout. Materializing records remain durable but private until final placement is published.

Scope Docker resources to the daemon owner and validate both container and volume before every lifecycle operation and creation race. Explicit Local and Worktree selection remains behaviorally unchanged, including reusable Local script terminals and the shared common-Git watcher.

Real-provider manual QA uses ignored checkout-local configuration and is intentionally not committed.
2026-08-12 18:28:06 +02:00
Mohamed Boudra 359d5443a9 feat(workspaces): probe providers in selected runtimes
Pre-creation provider truth now comes from an invisible real runtime workspace and reuses the normal provider snapshot pipeline. Probe records are owned separately, keeping them structurally absent from ordinary workspace projections.

Setup eligibility follows fresh content materialization rather than lifecycle resource ownership. Adopted Local directories never execute repository setup, while fresh Worktree, Docker, and external content may; provider probes never execute repository setup.
2026-08-12 13:02:21 +02:00
Mohamed Boudra 3a36c76733 feat(workspaces): choose runtime when creating workspaces
Expose the runtime catalog and carry an explicit runtime ID through workspace creation while preserving omitted-field compatibility for older clients.

The runtime POC made useGitActions require WorkspaceGitBoundary, but Command Center mounted that consumer without the boundary and crashed. Bind the active workspace at that consumer.

Keep provisional Worktree reservations unpublished until final placement so observers never bind the source checkout as the new workspace. Gate legacy cwd Git compatibility by hostVisiblePath instead of runtime kind because Local and Worktree remain host-visible runtimes.
2026-08-12 11:22:55 +02:00
Mohamed Boudra 6ca27a9b12 feat(workspaces): publish external runtime contract
Publish the rootless workspace runtime protocol, enforce module and host-execution boundaries, and prove an independently authored command runtime across lifecycle, PTY, files, Git, providers, and CI.
2026-08-11 22:14:47 +02:00
Mohamed Boudra cbf59f4c96 feat(workspaces): own lifecycle through runtimes
Reconcile runtime placement and lifecycle by workspace identity, preserve state through archive and recovery, and make deletion, observation replay, scripts, and resource cleanup convergent across local, worktree, and Docker runtimes.
2026-08-11 16:49:27 +02:00
Mohamed Boudra 36241cbd87 feat(workspaces): run providers through workspace runtimes
Bind provider discovery, state, and subprocess launches to workspace identity so selected runtimes cannot fall back to host execution. Preserve explicit legacy behavior and isolate provider snapshots by runtime generation.
2026-08-11 12:18:42 +02:00
Mohamed Boudra 0520481966 feat(workspaces): route Git operations through runtimes
Bind Git callers to workspace identity so selected repositories cannot collapse onto cwd-keyed state.

Run commands and observation through runtime capabilities while preserving explicit cwd-bound behavior for legacy records.
2026-08-11 07:40:20 +02:00
Mohamed Boudra b682f56bb9 feat(workspaces): route file access through runtimes
Keep browsing, streaming, editing, downloads, and live file observation inside the selected workspace runtime. Helper processes own cancellation and teardown so remote workspaces never fall back to host paths.
2026-08-11 01:55:27 +02:00
Mohamed Boudra d340365245 feat(workspaces): route interactive terminals through runtimes
Keep terminal state in Paseo while allocating PTYs and running terminal commands in the selected workspace runtime. Runtime exit and cleanup remain authoritative across worker and wrapper failures.
2026-08-11 00:47:09 +02:00
Mohamed Boudra 4898523351 feat(workspaces): introduce runtime execution boundary
Establish the pipe-mode vertical slice before migrating terminals, files, Git, and providers. Runtime selection is immutable per workspace, and external runtimes remain unavailable to path-based daemon surfaces until those capabilities move behind the boundary.
2026-08-10 22:57:54 +02:00
Mohamed Boudra a18d9ec3d8 test(workspaces): characterize runtime boundaries 2026-08-10 20:30:45 +02:00
1120 changed files with 46010 additions and 61938 deletions
+3
View File
@@ -35,6 +35,9 @@ hub:
server:
- "packages/server/**"
- "runtimes/fixture/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/app/e2e/support/fixtures/recording.*"
desktop:
+17
View File
@@ -102,6 +102,9 @@ jobs:
- name: Lint
run: npm run lint
- name: Enforce workspace runtime ownership boundaries
run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs
typecheck:
name: typecheck
needs: changes
@@ -129,6 +132,8 @@ jobs:
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
@@ -163,6 +168,18 @@ jobs:
- name: Build server dependencies
run: npm run build:server-deps
- name: Test workspace runtime command contract package
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper
- name: Prove the command runtime fixture is independently installable
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/workspace-runtime-standalone.test.mjs
- name: Prove clean server builds own public prerequisites
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/server-clean-build.test.mjs
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
+3
View File
@@ -16,6 +16,9 @@ on:
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/desktop/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
+139 -41
View File
@@ -84,6 +84,144 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["packages/server/src/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**"
],
"message": "Workspace runtime internals are owned by their public entry points."
},
{
"group": ["**/workspace-helper/internal/**"],
"message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients."
},
{
"group": [
"**/workspace-runtime/integration/**",
"**/workspace-runtime/git-observation/internal/**"
],
"message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/workspace-runtime/index.ts",
"packages/server/src/server/workspace-runtime/command/index.ts",
"packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-helper/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/service.ts"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{ "name": "child_process", "message": "Use the bound workspace runtime." },
{ "name": "node:child_process", "message": "Use the bound workspace runtime." },
{ "name": "fs", "message": "Use the bound workspace runtime." },
{ "name": "node:fs", "message": "Use the bound workspace runtime." },
{ "name": "fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "node:fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "module", "message": "Dynamic loaders bypass workspace ownership." },
{
"name": "node:module",
"message": "Dynamic loaders bypass workspace ownership."
},
{ "name": "which", "message": "Use the bound workspace runtime." }
],
"patterns": [
{
"group": ["**/utils/run-git-command.{js,ts}"],
"message": "Use the bound workspace runtime."
},
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**",
"**/workspace-runtime/git-observation/internal/**",
"**/workspace-helper/internal/**"
],
"message": "Use the owning public entry point."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.test.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "node:module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "@getpaseo/server",
"message": "The external runtime may consume only the published command contract."
}
],
"patterns": [
{
"group": [
"@getpaseo/server/**",
"**/packages/server/**",
"**/workspace-runtime/**/internal/**",
"**/workspace-helper/**/internal/**"
],
"message": "The external runtime may consume only the published command contract."
}
]
}
]
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
@@ -94,11 +232,6 @@
"error",
{
"paths": [
{
"name": "react-native",
"importNames": ["TextInput"],
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
},
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
@@ -125,11 +258,6 @@
"error",
{
"paths": [
{
"name": "react-native",
"importNames": ["TextInput"],
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
},
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
@@ -177,11 +305,6 @@
"error",
{
"paths": [
{
"name": "react-native",
"importNames": ["TextInput"],
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
},
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
@@ -207,11 +330,11 @@
"packages/app/src/desktop/browser/pane/index.tsx",
"packages/app/src/desktop/browser/pane/index.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/explorer-sidebar.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
@@ -269,11 +392,6 @@
"error",
{
"paths": [
{
"name": "react-native",
"importNames": ["TextInput"],
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
},
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
@@ -294,26 +412,6 @@
"packages/app/src/desktop/components/pair-device-section.tsx",
"packages/app/src/screens/new-workspace-screen.tsx"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native",
"importNames": ["TextInput"],
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
}
]
}
]
}
},
{
"files": [
"packages/app/src/components/ui/text-input/**/*.{ts,tsx}",
"packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts"
],
"rules": {
"no-restricted-imports": "off"
}
-63
View File
@@ -1,68 +1,5 @@
# Changelog
## 0.5.0-beta.3 - 2026-08-20
### Fixed
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
## 0.5.0-beta.2 - 2026-08-19
### Added
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
### Fixed
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
## 0.5.0-beta.1 - 2026-08-18
### Added
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
### Improved
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- The text size setting now scales the whole interface
### Fixed
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
- Fixed resumed subagents splitting into separate rows
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
- Fixed workspace pull request status being lost on reload
- Fixed hidden terminal tabs stopping output in the focused workspace
## 0.4.0 - 2026-08-13
### Breaking
+3 -1
View File
@@ -22,8 +22,10 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+3 -6
View File
@@ -135,9 +135,7 @@ Running provider-native subagents contribute `running` to the workspace owned by
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
The rows combine two kinds of children:
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
@@ -160,7 +158,6 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
@@ -168,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The **Archive finished** row at the foot of the panel covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
@@ -188,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the panel's list grow; the pill only counts them. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
### Cross-client tab dismissal
+2 -2
View File
@@ -82,11 +82,11 @@ Or from `packages/app`:
```bash
# Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug
# Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release
# Clear generated Android project
+28 -30
View File
@@ -64,21 +64,21 @@ not retain non-Git directories.
**Key modules:**
| Module | Responsibility |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| Module | Responsibility |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/workspace-runtime/` | Runtime-neutral workspace lifecycle, execution, files, Git observation |
| `server/provider-probe/` | Invisible runtime-bound workspaces for pre-creation provider discovery |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -87,6 +87,19 @@ agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### Workspace runtime packages
`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire
contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution
executable and its typed binding. The daemon depends on those two packages and registers command
runtimes without importing an implementation.
`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own
repositories and depend on published versions of the two public workspace packages. CLI, Desktop,
server, release workflows, and the daemon image do not depend on, bundle, publish, or register an
implementation. Tests invoke registered runtimes through their public command, never through source
imports.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
@@ -94,10 +107,6 @@ facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config.
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
@@ -106,7 +115,6 @@ Cross-platform React Native app that connects to one or more daemons.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
@@ -127,11 +135,6 @@ generation. The daemon retains only the latest projection per entity and bounded
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
Workspace label definitions use a separate, explicitly subscribed sequence. The list request both
fetches and grants live updates for that session. A current cursor receives an empty correlated
catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label
traffic. Workspace assignments stay on the workspace directory sequence.
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
@@ -142,7 +145,6 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo script ls/start/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo project create/ls/rename/delete`
- `paseo workspace create/ls/rename/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
@@ -180,10 +182,6 @@ Electron wrapper for macOS, Linux, and Windows.
- Native file access for workspace integration
- Same WebSocket client as mobile app
The desktop does not manage agent skills. It retains one compatibility reader for the old
`skill-selection.json`, imports that preference into its managed local daemon, then deletes the old
file after the daemon confirms persistence.
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
+25 -50
View File
@@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
The workspace registry model defines public placement once: initial directory/worktree
construction, mutable reconciliation fields, and the persisted-to-wire projection.
`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent
imports, and worktree creation do not construct records independently.
For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd`
is required compatibility and presentation data: an adopted local directory, the actual host
worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and
never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and
file-manager actions. Private roots, revisions, execution domains, container and volume IDs,
labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)`
and are never written to the workspace record.
Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`,
and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected
worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are
owned by the runtime driver.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
@@ -57,8 +61,6 @@ $PASEO_HOME/
├── projects/
│ ├── projects.json # Project registry
│ ├── workspaces.json # Workspace registry
│ ├── workspace-labels.json # Shared host-local label catalog
│ ├── workspace-labels.transaction.json # Recoverable catalog/assignment compound commit
│ └── icons/ # Host-local custom project icon images
├── runtime/
│ └── managed-processes/
@@ -177,10 +179,6 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`.
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed
skill directories and keeps config plus filesystem convergence behind one serialized owner.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
@@ -224,9 +222,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
local: { modelsDir: string }
},
agents: {
skills?: {
selection?: { mode: "all" } | { mode: "custom", skills: string[] }
},
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
@@ -235,8 +230,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
providers: [{ provider, model?, thinkingOptionId? }]
}
},
pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
@@ -434,7 +427,8 @@ Array of workspace records. A workspace is a specific working directory within a
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. |
| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
@@ -443,38 +437,19 @@ Array of workspace records. A workspace is a specific working directory within a
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
### Workspace label catalog
**Path:** `$PASEO_HOME/projects/workspace-labels.json`
The catalog is shared by every workspace on one host. A definition contains a display name and one
of the ten identity colour names (`WORKSPACE_LABEL_COLORS` in
`packages/protocol/src/workspace-labels.ts`); trimmed, collapsed, case-insensitive name identity is
unique within that file. Definitions have no portable ID or principal owner and remain after their
last assignment. Editing a label takes a new name, a new colour, or both in one commit, so the two
fields cannot land half-applied. Workspaces store label names, so a rename and a delete rewrite
workspace assignments through one serialized compound commit while a recolour is catalog-only; a
rename onto a name the host already has is refused rather than merged. A prepared
`workspace-labels.transaction.json` contains both before and after images. The daemon writes the
catalog and workspace files, then atomically changes the transaction to committed; that phase
change is the durable commit point. Recovery rolls prepared transactions back before either
directory is served. A committed marker proves both data files were already written, so recovery
only loads the current catalog and retries marker cleanup; it never reapplies stale workspace
after-images over later registry mutations. A request rejected before the commit point therefore
cannot take effect after restart. If the live daemon cannot determine or restore the durable state,
the workspace registry freezes every write and later label mutations fail with
`workspace_label_storage_uncertain` until daemon restart performs recovery. Reads remain available,
but may reflect the last acknowledged cache until restart. Workspace directory and catalog updates
publish only after the commit point; publication failure does not roll durable state back.
For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove
the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project
removal destroys each selected runtime before removing its workspace record. Records without
`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
+6 -8
View File
@@ -28,13 +28,11 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Base size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Code size remains independent.
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
@@ -66,7 +64,7 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -227,13 +225,13 @@ There is exactly one token per status signal — `statusSuccess`, `statusDanger`
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Status pills use the status token for text on the shared `surface3` and `border` shell. The neutral shell keeps the signal legible without manufacturing translucent colors outside the theme. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
@@ -244,7 +242,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
+2 -63
View File
@@ -217,67 +217,6 @@ PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
For warm workspace switching, point the benchmark at an app backed by seeded
daemon state:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 \
npm run profile:workspace-switching --workspace=@getpaseo/app
```
The benchmark first warms `Cmd+1` through `Cmd+7`, then records a rapid seven-workspace
burst. It separately warms the three-entry workspace deck LRU and records `Cmd+1` through
`Cmd+3` without waits between keys. Both scenarios report keydown-to-activation latency and
React commits on the same browser clock. Set `PASEO_PROFILE_WORKSPACE_DIGITS`,
`PASEO_PROFILE_WORKSPACE_LRU_SIZE`, or `PASEO_PROFILE_WARM_QUIET_MS` to change the shape. Set
`PASEO_PROFILE_DUMP_COMMITS=1` to include the nested component breakdown for every commit.
Set `PASEO_PROFILE_RETAINED_REPEATS=5` to repeat the retained-LRU burst for a less noisy sample.
Set `PASEO_PROFILE_CPU_PATH=/tmp/workspace-switch.cpuprofile` to run a separate retained-LRU
capture after the latency scenarios and write a raw CDP CPU profile without contaminating their
timings.
Set `PASEO_PROFILE_TRACE_PATH=/tmp/workspace-switch.trace.json` to run another separate retained-LRU
capture and write a Chromium Performance trace with User Timing marks and screenshots. Open the
trace in the Chrome DevTools Performance panel. Set `PASEO_PROFILE_TRACE_INVALIDATIONS=1` only for a
focused invalidation capture; React Native's generated stacks make that mode highly intrusive.
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
`PASEO_PROFILE_CPU_PATH=/tmp/explorer-toggle.cpuprofile` for separate Chromium captures. Override
the app URL, daemon port, workspace, or server with the corresponding `PASEO_PROFILE_*` variables.
For sustained composer typing, run the paired composer-versus-textarea benchmark against a seeded
daemon:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 \
npm run profile:composer-typing --workspace=@getpaseo/app
```
The benchmark opens the first workspace, preserves its existing draft, and dispatches 300 printable
keys at a fixed 16 ms cadence without waiting for each key to paint. It measures renderer
`keydown` to the next paint opportunity, verifies that every character survived, alternates the
composer and plain-textarea control across three runs, then restores the original draft. The report
includes percentiles, frame coalescing, input processing, React work, long tasks, slow samples, and
Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TYPING_CADENCE_MS`,
`PASEO_PROFILE_TYPING_REPEATS`, or `PASEO_PROFILE_WORKSPACE_DIGIT` to change the scenario. Optional
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them.
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -365,7 +304,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
@@ -523,7 +462,7 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo project create/ls/rename/delete`, `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
+1 -1
View File
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
+1 -3
View File
@@ -17,9 +17,7 @@ Each layout owns only the routes directly inside its directory.
`h/[serverId]/index`.
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
relative screen names: `index`, `workspace/[workspaceId]/index`,
`agent/[agentId]`, `sessions`, `open-project`, `settings`,
`plugin/[pluginId]/[surfaceId]`, and
`plugin/[pluginId]/[contributionKind]/[contributionId]`.
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
Expo Router warns with `[Layout children]: No route named ...` when a layout
registers grandchildren. Treat that warning as a route-tree bug. On native, that
-6
View File
@@ -61,12 +61,6 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a
states) is owned by `components/ui/control-geometry.ts` — controls never
declare their own, and screens never nudge global component styles to align
a row.
- Every text field consumes `EditingTextInput`, directly or through a UI
wrapper. The editing surface owns in-progress text; React observes committed
edits through `onChangeText`, and programmatic mutations use the input's
`replaceText` command. Its props omit `value` and `defaultValue`, web IME
candidates stay unpublished until composition commits, and lint rejects raw
React Native `TextInput` imports outside the primitive.
- The form declares one size for all fields: `sm` on desktop, `md` compact
(`useIsCompactFormFactor`).
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
+6 -5
View File
@@ -5,8 +5,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live.
- **Runtime** — The selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host.
- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization.
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
@@ -17,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Worktree** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -42,8 +44,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
-37
View File
@@ -26,15 +26,6 @@ when you have actually looked at it on a phone.
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
## When the items differ per form factor
A menu whose contents depend on what else is on screen gets one component per surface, not one
component with `isMobile` branches inside it. The workspace header menu is the example
(`packages/app/src/screens/workspace/workspace-header-menu.tsx`): compact has no tab strip, so it
carries new-tab actions, while wide leaves those to the strip's `+` menu and lists workspace
actions only. Items both surfaces share live in one component that each menu renders, from the same
callbacks, so the two can't drift.
## Selecting an item on iOS
An item that closes the menu runs its action after a fixed grace period on iOS, not immediately:
@@ -46,12 +37,6 @@ and it loses. Selections used to be dropped entirely for exactly that reason.
The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second
delay on top of it, and don't read state that the same press mutated.
This is why a row inside a menu surface goes through `selectItem` rather than calling its handler
from its own `Pressable`. `MenuItem` is not the only row shape on the engine — `ComposerTrackRow`
is another, with its own icons and hover-revealed actions — but both hand the press to the engine
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
@@ -77,25 +62,6 @@ Opening a submenu truncates the path to the depth of the trigger that opened it.
sliding the pointer across a row of triggers would stack up every flyout it passed instead of
swapping between them.
## A page that takes input
A menu page can hold a small form — `MenuTextField` is the field for it, drawn as a row's own
fill so its text lands on the same rail as the labels above it. Two things have to be true for
that page, and both are set on the page definition rather than discovered later:
- **`hoverIntent: false`.** A branch you skim is opened and closed by the pointer; a form is not.
Without this the page opens on a pointer that was only passing through, and dismisses itself —
draft and all — the moment your hands move to the keyboard and the mouse drifts off the flyout.
While such a page is open, the whole surface stops closing on hover, because the parent flyout
leads back to the same dismissal.
- **The compact presentation has to be a sheet.** `MenuTextField` resolves to
`BottomSheetTextInput` on compact native, which reads the sheet's context and has none in a
popover.
The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its largest snap
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
not grow, and the keyboard comes up over the field you are typing into.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
@@ -120,9 +86,6 @@ opens on press.
| `selected` | This is the chosen value | A check, and nothing else |
| `active` | This row's submenu is open | The fill, and no check |
`selected` also announces itself as `aria-checked`, so a multi-select page is audible as the list
of on/off things it is.
A selected row does **not** get a background. A check and a fill are two separate claims about the
same state, and showing both makes a chosen row compete with the row the pointer is actually on.
`showSelectedCheck` moves the check to a reserved leading column when a group needs to stay
+70 -95
View File
@@ -1,9 +1,8 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
this first slice does not sandbox it.
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Install a directory source
@@ -35,27 +34,22 @@ The daemon stores directory sources under the root `plugins` object:
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The plugin system is disabled unless `pluginsEnabled` is `true`.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
```text
my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
SDK contract changes.
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
```json
{
@@ -68,69 +62,55 @@ The config key is the runtime plugin ID. The manifest ID is the default selected
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Never enable plugins on a user's behalf without explicit permission. Before asking, check the
target daemon's current `pluginsEnabled` value. State that plugins are trusted, unsandboxed code:
backend code can access the daemon machine, while client contributions run inside the Paseo app.
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
log credentials or tokens.
## Contribute behavior and UI
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
code lives behind filename boundaries:
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
removes UI registrations from the server bundle and RPC registrations from the client bundle before
resolving dependencies.
| Suffix | Owns |
| -------------- | -------------------------------------------------------------------- |
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
```tsx
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
`layout.compact`. See `public-docs/plugins/reference.md`.
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
| Module | Use it for |
| ------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | hooks and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
server bundle.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { Greeting } from "./greeting.client";
import { createGreeting } from "./greeting.server";
import { greetRpc } from "./greeting.shared";
function Greeting() {
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, createGreeting);
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
return () => {};
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
return () => undefined;
}
```
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
@@ -139,53 +119,48 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess.
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents,
providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
Workspace panels and Command Center items remain client contributions. The daemon transports their
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
workspace-layout store access.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { search } from "./issues.server";
import { issues, searchIssues } from "./issues.shared";
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, search);
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => {};
return () => undefined;
}
```
+5 -10
View File
@@ -81,10 +81,6 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
@@ -107,7 +103,9 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it.
Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
@@ -118,13 +116,13 @@ that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global.
---
@@ -143,8 +141,6 @@ Keep the protocol shape provider-agnostic. Do not add provider-specific renderer
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
---
## ACP Provider Checklist
@@ -430,7 +426,6 @@ interface AgentSession {
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
+2
View File
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
- A video of the interaction
- Logs, requests, responses
Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak.
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
+1 -1
View File
@@ -120,7 +120,7 @@ republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client plugin server cli; do
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
-2
View File
@@ -24,9 +24,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
## Measuring
-10
View File
@@ -124,16 +124,6 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
npm run build:desktop -- --publish never --linux --x64 --dir
```
### Undeclared peer dependencies break app.asar
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
Prefer a `@lezer/*` grammar. When a language only ships inside an editor extension, vendor the grammar into `packages/highlight/src/<lang>/` — see `svelte/`, `nix/`, and `csharp/`.
### Desktop browser regression
The desktop browser E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click.
-15
View File
@@ -42,16 +42,6 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
condition clears.
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
boolean, so the button reports in-flight and the callout returns to `error` when the attempt fails.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
@@ -183,11 +173,6 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
the Paseo timeline, so one visible response can span several canonical turns without a user message
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
+5
View File
@@ -178,6 +178,11 @@ buildNpmPackage {
exit 1
fi
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
+1 -1
View File
@@ -1 +1 @@
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
+123 -110
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -20,7 +20,10 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli"
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
],
"devDependencies": {
"@types/ws": "^8.5.14",
@@ -2549,6 +2552,19 @@
"node": ">=16"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
@@ -6243,12 +6259,12 @@
"resolved": "packages/expo-two-way-audio",
"link": true
},
"node_modules/@getpaseo/highlight": {
"resolved": "packages/highlight",
"node_modules/@getpaseo/fixture-workspace-runtime": {
"resolved": "runtimes/fixture",
"link": true
},
"node_modules/@getpaseo/plugin": {
"resolved": "packages/plugin",
"node_modules/@getpaseo/highlight": {
"resolved": "packages/highlight",
"link": true
},
"node_modules/@getpaseo/protocol": {
@@ -6267,6 +6283,14 @@
"resolved": "packages/website",
"link": true
},
"node_modules/@getpaseo/workspace-helper": {
"resolved": "packages/workspace-helper",
"link": true
},
"node_modules/@getpaseo/workspace-runtime-contract": {
"resolved": "packages/workspace-runtime-contract",
"link": true
},
"node_modules/@gorhom/bottom-sheet": {
"version": "5.2.14",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
@@ -9560,6 +9584,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -11127,6 +11155,21 @@
"nanoid": "^3.3.11"
}
},
"node_modules/@replit/codemirror-lang-csharp": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-lang-csharp/-/codemirror-lang-csharp-6.2.0.tgz",
"integrity": "sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==",
"license": "MIT",
"peerDependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@replit/codemirror-vim": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
@@ -11647,32 +11690,6 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@shopify/react-native-skia": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz",
"integrity": "sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A==",
"license": "MIT",
"dependencies": {
"canvaskit-wasm": "0.40.0",
"react-reconciler": "0.31.0"
},
"bin": {
"setup-skia-web": "scripts/setup-canvaskit.js"
},
"peerDependencies": {
"react": ">=19.0",
"react-native": ">=0.78",
"react-native-reanimated": ">=3.19.1"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -14128,12 +14145,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
@@ -16062,15 +16073,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvaskit-wasm": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz",
"integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==",
"license": "BSD-3-Clause",
"dependencies": {
"@webgpu/types": "0.1.21"
}
},
"node_modules/cardinal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz",
@@ -28483,6 +28485,16 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -30922,27 +30934,6 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -36140,7 +36131,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36154,18 +36145,16 @@
"@getpaseo/client": "*",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/plugin": "*",
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@shopify/react-native-skia": "2.2.12",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -37192,12 +37181,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.3",
"@getpaseo/protocol": "0.5.0-beta.3",
"@getpaseo/server": "0.5.0-beta.3",
"@getpaseo/client": "0.4.0",
"@getpaseo/protocol": "0.4.0",
"@getpaseo/server": "0.4.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37433,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@getpaseo/protocol": "0.5.0-beta.3",
"@getpaseo/relay": "0.5.0-beta.3",
"@getpaseo/protocol": "0.4.0",
"@getpaseo/relay": "0.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37447,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37690,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38586,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38610,13 +38599,13 @@
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/lr": "^1.4.8",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@replit/codemirror-lang-csharp": "^6.2.0",
"lezer-elixir": "^1.1.2"
},
"devDependencies": {
@@ -38625,13 +38614,9 @@
}
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0-beta.3",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"name": "@paseo/plugin",
"version": "0.3.0",
"devDependencies": {
"@getpaseo/client": "0.5.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38625,13 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0-beta.3",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38644,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,20 +38862,22 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.3",
"@getpaseo/highlight": "0.5.0-beta.3",
"@getpaseo/plugin": "0.5.0-beta.3",
"@getpaseo/protocol": "0.5.0-beta.3",
"@getpaseo/relay": "0.5.0-beta.3",
"@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0",
"@getpaseo/relay": "0.4.0",
"@getpaseo/workspace-helper": "0.4.0",
"@getpaseo/workspace-runtime-contract": "0.4.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -39736,16 +39722,6 @@
"node": "20 || >=22"
}
},
"packages/server/node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"packages/server/node_modules/openai": {
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
@@ -39940,7 +39916,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41542,6 +41518,43 @@
"optional": true
}
}
},
"packages/workspace-helper": {
"name": "@getpaseo/workspace-helper",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"bin": {
"paseo-workspace-helper": "dist/executable.mjs"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
},
"packages/workspace-runtime-contract": {
"name": "@getpaseo/workspace-runtime-contract",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"runtimes/fixture": {
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.4.0",
"dependencies": {
"@getpaseo/workspace-helper": "0.4.0",
"@getpaseo/workspace-runtime-contract": "0.4.0",
"node-pty": "1.2.0-beta.15"
},
"bin": {
"paseo-fixture-workspace-runtime": "src/index.mjs"
}
}
}
}
+20 -13
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0-beta.3",
"version": "0.4.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -31,7 +31,10 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli"
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
],
"scripts": {
"dev": "npm run dev:server",
@@ -47,21 +50,24 @@
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@getpaseo/plugin",
"build:plugin": "npm run build --workspace=@paseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:client && concurrently --kill-others-on-fail --names highlight,plugin,relay --prefix-colors yellow,green,blue \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\"",
"build:server-deps:clean": "npm run build:client:clean && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean",
"build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
"build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build:plugin && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build:plugin:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
@@ -72,6 +78,7 @@
"format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check",
"lint": "oxlint",
"check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs",
"lint:fix": "oxlint --fix",
"knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
@@ -102,11 +109,11 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/plugin && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/plugin && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/plugin --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/plugin --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/plugin --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/plugin --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
@@ -2,7 +2,6 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
@@ -32,7 +31,8 @@ test("adds a changed file to the focused chat without replacing its composer dra
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await openChangesPanel(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
);
});
const gate = await installDaemonWebSocketGate(page);
gate.setAgentStreamItemSuppressed("assistant_message", true);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
@@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
gate.holdNextServerMessage("fetch_agent_timeline_response");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.truncateHeldTimelineAfterLast("user_message");
gate.setServerMessageSuppressed("fetch_agent_timeline_response", true);
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
gate.setServerMessageSuppressed("fetch_agent_timeline_response", false);
gate.setAgentStreamItemSuppressed("assistant_message", false);
} finally {
await workspace.cleanup();
}
@@ -28,7 +28,7 @@ async function completeSubmittedTurn(
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
await page.getByRole("menuitem", { name: "Rewind conversation", exact: true }).click();
await page.getByRole("button", { name: "Rewind conversation", exact: true }).click();
}
async function expectTurnCompletesNormally(
@@ -24,23 +24,18 @@ import {
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
@@ -268,20 +263,14 @@ async function submitMessageThatWillBeRejected(page: Page, prompt: string): Prom
async function expectRejectedSubmissionRestored(
page: Page,
input: { prompt: string; errorMessage: string; preservesActiveTurn?: boolean },
input: { prompt: string; errorMessage: string },
): Promise<void> {
await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
if (input.preservesActiveTurn) {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
return;
}
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
}
@@ -294,76 +283,6 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function configureSteerInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
await expect(page).toHaveURL(/\/settings\/general$/);
await selectSteerInSettings(page);
}
async function selectSteerInSettings(page: Page): Promise<void> {
await selectSendBehaviorInSettings(page, "Steer", "steer");
}
/** Steer is the default, so the interrupt path only gets exercised by opting back into it. */
async function configureInterruptInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
await expect(page).toHaveURL(/\/settings\/general$/);
await selectSendBehaviorInSettings(page, "Interrupt", "interrupt");
}
async function selectSendBehaviorInSettings(
page: Page,
buttonName: string,
stored: string,
): Promise<void> {
await page.getByRole("button", { name: buttonName, exact: true }).click();
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
return raw ? (JSON.parse(raw) as { sendBehavior?: unknown }).sendBehavior : null;
})
.toBe(stored);
}
async function replaySteeredSleepTurnInBrowser(
page: Page,
testInfo: { workerIndex: number },
shape: "claude" | "codex",
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
gate.holdNextShellToolCall("completed");
await gotoAppShell(page);
await openSettings(page);
await selectSteerInSettings(page);
const agent = await startRunningMockAgent(page, {
prefix: `steer-replay-${shape}-${testInfo.workerIndex}-`,
model: "ten-second-stream",
prompt: `Replay a ${shape}-shaped foreground shell tool call while the user steers this turn.`,
});
try {
await expect(page.getByTestId("tool-call-badge").last()).toBeVisible({ timeout: 30_000 });
await expectComposerVisible(page);
await submitMessage(page, "hello");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(0);
await expectInFlightForkAvailable(page);
await gate.waitForHeldServerMessage();
gate.releaseHeldServerMessage();
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Fork chat" }).last()).toBeVisible();
} finally {
gate.restore();
await agent.cleanup();
}
}
async function queueMessage(page: Page, prompt: string): Promise<void> {
await fillComposerDraft(page, prompt);
await sendDraftToQueue(page);
@@ -435,7 +354,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
Array.from({ length: 3 }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`,
@@ -460,9 +379,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
]);
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
@@ -774,7 +691,7 @@ async function expectRenderedBefore(first: Locator, second: Locator): Promise<vo
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
}
@@ -948,9 +865,9 @@ test.describe("Agent message submission", () => {
gate.holdNextClientRequest("send_agent_message_request");
await fillComposerDraft(page, "Replace the running turn without duplicating its action.");
await expect(page.getByRole("button", { name: "Send and steer", exact: true })).toHaveCount(
1,
);
await expect(
page.getByRole("button", { name: "Send and interrupt", exact: true }),
).toHaveCount(1);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount(
0,
@@ -1110,115 +1027,6 @@ test.describe("Agent message submission", () => {
await retryRestoredSubmission(page, prompt);
});
test("restores an ambiguous Steer failure without retrying or interrupting", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const prompt = "Restore this ambiguous Steer submission.";
const agent = await startRunningMockAgent(page, {
prefix: `steer-ambiguous-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while a Steer request fails.",
featureValues: { mockSteerAmbiguousFailures: 1 },
});
try {
await configureSteerInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const sendsBefore = gate.getClientRequestCount("send_agent_message_request");
const cancelsBefore = gate.getClientRequestCount("cancel_agent_request");
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, {
prompt,
errorMessage: "Requested mock steer transport failure",
preservesActiveTurn: true,
});
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({
text: prompt,
activeTurnBehavior: "steer",
});
} finally {
gate.restore();
await agent.cleanup();
}
});
test("keeps an optimistic Steer prompt inside the active turn before acknowledgement", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await startRunningMockAgent(page, {
prefix: `steer-optimistic-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user steers it.",
});
try {
await configureSteerInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
gate.holdNextServerMessage("send_agent_message_response");
await submitMessage(page, "hello");
await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("sends interrupt behavior on the wire when the user opts out of steering", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `interrupt-submission-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active until the user interrupts it.",
});
try {
await configureInterruptInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const prompt = "Interrupt the running turn.";
await fillComposerDraft(page, prompt);
await expect(
page.getByRole("button", { name: "Send and interrupt", exact: true }),
).toHaveCount(1);
await composerLocator(page).press("Enter");
const request = await gate.waitForRequest();
expect(request.activeTurnBehavior).toBe("interrupt");
gate.accept();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
});
test("replays Codex-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "codex");
});
test("restores overlapping queued sends when their connection fails", async ({
page,
}, testInfo) => {
@@ -1272,7 +1080,7 @@ test.describe("Agent message submission", () => {
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
page,
}, testInfo) => {
test.setTimeout(240_000);
test.setTimeout(120_000);
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
});
@@ -1,215 +0,0 @@
import { access } from "node:fs/promises";
import { expect, test as base } from "../support/fixtures";
import {
answerNextRemovalWarning,
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
expectInstalledSkills,
expectSavedSelection,
expectSkillFile,
openAgentSkillsSettings,
openSkillSelection,
saveSkillSelection,
startAgentSkillsSandbox,
toggleSkill,
type AgentSkillsSandbox,
} from "../support/helpers/agent-skills";
import { readVerticalGap, waitForSettledPosition } from "../support/helpers/sheet-layout";
const test = base.extend<{ skills: AgentSkillsSandbox }>({
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring for fixture dependency discovery.
skills: async ({}, provide) => {
const sandbox = await startAgentSkillsSandbox();
try {
await provide(sandbox);
} finally {
await sandbox.close();
}
},
});
test.describe("Host agent skills", () => {
test.describe.configure({ timeout: 120_000 });
test("installs a custom selection, cancels a draft, then installs all skills", async ({
page,
skills,
}) => {
const custom = skills.available.slice(0, 2);
const cancelledAddition = skills.available[2]!;
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, custom);
await saveSkillSelection(page);
await expectInstalledSkills(skills, custom);
await expectSavedSelection(skills, { mode: "custom", skills: custom });
await openSkillSelection(page);
await toggleSkill(page, cancelledAddition);
await cancelSkillSelection(page);
await expectInstalledSkills(skills, custom);
await expectSavedSelection(skills, { mode: "custom", skills: custom });
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSavedSelection(skills, { mode: "all" });
await expectInstalledSkills(skills, skills.available);
});
test("dismisses and then accepts removal confirmation", async ({ page, skills }) => {
const retained = skills.available.slice(0, 2);
const removed = skills.available.slice(2);
await skills.install({ mode: "all" });
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, retained);
const dismissedWarning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
const dismissedDialog = await dismissedWarning;
expect(dismissedDialog.message()).toContain("Remove deselected skills?");
for (const skill of removed) expect(dismissedDialog.message()).toContain(skill);
await expectInstalledSkills(skills, skills.available);
await expectSavedSelection(skills, { mode: "all" });
const acceptedWarning = answerNextRemovalWarning(page, "accept");
await saveSkillSelection(page);
expect((await acceptedWarning).message()).toContain(
"Anything you added inside those skill folders",
);
await expectInstalledSkills(skills, retained);
await expectSavedSelection(skills, { mode: "custom", skills: retained });
});
test("warns before removing a partial installation", async ({ page, skills }) => {
const partial = skills.available.at(-1)!;
const retained = skills.available.filter((name) => name !== partial);
await skills.install({ mode: "all" });
await skills.keepSkillOnlyIn(partial, "claude");
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, retained);
const warning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
expect((await warning).message()).toContain(partial);
await expectSkillFile(skills, "claude", partial, "SKILL.md");
await expectSavedSelection(skills, { mode: "all" });
});
test("warns without deleting user files from an externally restored skill", async ({
page,
skills,
}) => {
const retained = skills.available.slice(0, 2);
const drift = skills.available[2]!;
await skills.install({ mode: "custom", skills: retained });
await skills.placeSkillOnDisk(drift, "claude", {
"SKILL.md": `# ${drift}\n`,
"notes/mine.md": "user notes",
});
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
const warning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
expect((await warning).message()).toContain(drift);
await expectSkillFile(skills, "claude", drift, "notes/mine.md");
await expectSavedSelection(skills, { mode: "custom", skills: retained });
});
test("keeps the sheet open with an error when convergence fails", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await skills.blockAgentsDirectory();
await chooseCustomSkills(page, [skills.available[0]!]);
await saveSkillSelection(page);
await expect(
page.getByText("Could not save your skill selection.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
await skills.unblockAgentsDirectory();
await expectSavedSelection(skills, { mode: "all" });
});
test("serializes concurrent saves through the daemon command surface", async ({ skills }) => {
const firstSelection = { mode: "custom" as const, skills: [skills.available[0]!] };
const secondSelection = { mode: "custom" as const, skills: [skills.available[1]!] };
const first = skills.client.saveAgentSkillsSelection(firstSelection, skills.available);
const second = skills.client.saveAgentSkillsSelection(secondSelection, skills.available);
await expect(Promise.all([first, second])).resolves.toMatchObject([
{ confirmationRequired: null, selection: firstSelection },
{ confirmationRequired: null, selection: secondSelection },
]);
await expectInstalledSkills(skills, secondSelection.skills);
await expectSavedSelection(skills, secondSelection);
});
test("ignores agent config environment overrides", async ({ skills }) => {
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
await expect(
access(`${skills.home}/../ignored-claude-config/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
await expect(
access(`${skills.home}/../ignored-codex-home/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
});
});
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Agent skills sheet inset", () => {
test.describe.configure({ timeout: 120_000 });
test.describe("compact", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills, { compact: true });
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("desktop", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
});
@@ -1,87 +0,0 @@
import { expect, test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectTimelinePromptVisible,
openAgentTimeline,
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
disconnectViewedTimeline,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline sync retry", () => {
test("a refused catch-up offers a retry that reports pending, failure, and recovery", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
const callout = page.getByTestId("agent-timeline-sync-error");
const retry = page.getByTestId("agent-timeline-sync-retry");
await expect(callout).toBeVisible({ timeout: 30_000 });
await expect(retry).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("1-sync-error.png"), fullPage: true });
gate.holdTimelineResponses(agent.agentId);
await retry.click();
await gate.waitForHeldTimelineResponse();
await expect(retry).toBeDisabled();
await expect(retry).toHaveText("Retrying…");
await page.screenshot({ path: testInfo.outputPath("2-retry-pending.png"), fullPage: true });
gate.releaseHeldTimelineResponses();
await expect(retry).toBeEnabled({ timeout: 30_000 });
await expect(callout).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("3-retry-failed.png"), fullPage: true });
gate.allowTimelineResponses();
await retry.click();
await expect(callout).toBeHidden({ timeout: 30_000 });
await expectTimelinePromptVisible(page, agent.newestPrompt);
await page.screenshot({ path: testInfo.outputPath("4-recovered.png"), fullPage: true });
} finally {
gate.restore();
await agent.cleanup();
}
});
test("a refused first load offers a retry instead of a dead panel", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
gate.failTimelineResponses(agent.agentId);
await openAgentTimeline(page, agent);
const loadError = page.getByTestId("agent-load-error");
const retry = page.getByTestId("agent-load-error-retry");
await expect(loadError).toBeVisible({ timeout: 60_000 });
await expect(loadError).toContainText("already has an active writer");
await page.screenshot({ path: testInfo.outputPath("5-load-error.png"), fullPage: true });
await retry.click();
await expect(loadError).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: testInfo.outputPath("6-load-retry-failed.png") });
gate.allowTimelineResponses();
await retry.click();
await expect(loadError).toBeHidden({ timeout: 30_000 });
await expectTimelinePromptVisible(page, agent.newestPrompt);
await page.screenshot({ path: testInfo.outputPath("7-load-recovered.png"), fullPage: true });
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
@@ -45,9 +45,9 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
}
});
test("applies the base font size to settings text", async ({ page }) => {
test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
});
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
@@ -56,11 +56,11 @@ test("applies the base font size to settings text", async ({ page }) => {
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await expect(sectionTitle).toHaveCSS("font-size", "18px");
const fontSizeInput = page.getByLabel("Base font size");
await expect(fontSizeInput).toHaveValue("21");
const fontSizeInput = page.getByLabel("Interface font size");
await expect(fontSizeInput).toHaveValue("24");
await fontSizeInput.fill("12");
await fontSizeInput.press("Tab");
await expect(fontSizeInput).toHaveValue("12");
await expect(sectionTitle).toHaveCSS("font-size", "10px");
await expect(sectionTitle).toHaveCSS("font-size", "9px");
});
@@ -90,7 +90,11 @@ async function openTabSwitcher(page: Page) {
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
@@ -120,23 +124,15 @@ test.describe("mobile bottom sheet reopen", () => {
});
});
test("search selection returns to configuration options", async ({ page }) => {
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
});
await sheet.getByText("Ten second stream", { exact: true }).click();
await model.click();
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,7 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps";
@@ -15,7 +13,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await openChangesPanel(page);
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -35,7 +33,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesPanel(page);
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -52,15 +50,13 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectCommitDiffHeaderGeometry(panel);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-tab-commit_diff_/).hover();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
@@ -71,7 +67,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 480, height: 900 });
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
@@ -88,25 +84,3 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
},
});
}
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
const [header, content, name, stat] = await Promise.all([
panel.getByTestId("diff-file-0").boundingBox(),
panel.getByTestId("diff-file-0-header-content").boundingBox(),
panel.getByTestId("diff-file-0-name").boundingBox(),
panel.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(header).not.toBeNull();
expect(content).not.toBeNull();
expect(name).not.toBeNull();
expect(stat).not.toBeNull();
expect(header!.height).toBeCloseTo(30, 0);
expect(content!.x).toBeCloseTo(header!.x, 0);
expect(content!.width).toBeCloseTo(header!.width, 0);
expect(name!.x - header!.x).toBeCloseTo(12, 0);
expect(name!.x + name!.width).toBeLessThan(stat!.x);
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
1,
);
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
}
@@ -544,88 +544,6 @@ test.describe("Composer autocomplete", () => {
}
});
test("uses the live cursor when accepting a slash command", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/tdd");
await expect(
page
.getByTestId("composer-autocomplete-popover")
.getByText("/tdd", { exact: true })
.first(),
).toBeVisible({ timeout: 30_000 });
await input.evaluate((element) => {
if (!(element instanceof HTMLTextAreaElement)) {
throw new Error("Composer input is not a textarea");
}
element.setSelectionRange(0, 0);
element.dispatchEvent(new Event("select", { bubbles: true }));
element.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
}),
);
});
await expect(input).toHaveValue("/tdd");
const selectionStart = await input.evaluate(
(element) => (element as HTMLTextAreaElement).selectionStart,
);
expect(selectionStart).toBe(0);
} finally {
await agent.cleanup();
}
});
test("uses the live input when clicking a slash command", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/td");
const option = page
.getByTestId("composer-autocomplete-popover")
.getByText("/tdd", { exact: true })
.first();
await expect(option).toBeVisible({ timeout: 30_000 });
await option.evaluate((element) => {
const textarea = document.querySelector('textarea[aria-label="Message agent..."]');
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("Composer input is not a textarea");
}
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
if (!valueSetter) throw new Error("Textarea value setter is unavailable");
valueSetter.call(textarea, "/tdd suffix");
textarea.setSelectionRange(4, 4);
textarea.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "d suffix",
inputType: "insertText",
}),
);
(element as HTMLElement).click();
});
await expect(input).toHaveValue("/tdd suffix");
} finally {
await agent.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
@@ -1,121 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
scrollAgentChatToBottom,
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function composerHeight(page: Page): Promise<number> {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
}
async function expectComposerHeight(page: Page, expected: number): Promise<void> {
await expect.poll(() => composerHeight(page)).toBe(expected);
}
async function pressComposerKeyAndMeasureNextPaint(page: Page, key: string): Promise<number> {
const composer = composerLocator(page);
await installNextComposerPaintProbe(page);
await composer.press(key);
return readNextComposerPaintHeight(page);
}
async function typeComposerTextAndMeasureNextPaint(page: Page, text: string): Promise<number> {
const composer = composerLocator(page);
await installNextComposerPaintProbe(page);
await composer.pressSequentially(text);
return readNextComposerPaintHeight(page);
}
async function installNextComposerPaintProbe(page: Page): Promise<void> {
await composerLocator(page).evaluate((element) => {
Reflect.set(
globalThis,
"__composerNextPaintHeight",
new Promise<number>((resolve) => {
element.addEventListener(
"input",
() => {
requestAnimationFrame(() => resolve(element.getBoundingClientRect().height));
},
{ once: true },
);
}),
);
});
}
async function readNextComposerPaintHeight(page: Page): Promise<number> {
return page.evaluate(() => Reflect.get(globalThis, "__composerNextPaintHeight"));
}
test("blank composer lines remain present and keep their measured height", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-whitespace-",
title: "Composer whitespace",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const composer = composerLocator(page);
const blankLines = "\n\n\n\n\n";
const collapsedHeight = await composerHeight(page);
await test.step("grow the composer with blank lines followed by text", async () => {
await composer.fill(`${blankLines}x`);
await expect(composer).toHaveValue(`${blankLines}x`);
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
});
await test.step("delete only the text without losing the blank lines or height", async () => {
const expandedHeight = await composerHeight(page);
await composer.press("Backspace");
await expect(composer).toHaveValue(blankLines);
await expectComposerHeight(page, expandedHeight);
});
await test.step("grow on the first paint of a newline and stay stable when it receives a glyph", async () => {
await composer.fill("alpha\nbeta");
const filledLineHeight = await composerHeight(page);
const trailingLineHeight = await pressComposerKeyAndMeasureNextPaint(page, "Shift+Enter");
expect(trailingLineHeight).toBeGreaterThan(filledLineHeight);
const filledTrailingLineHeight = await typeComposerTextAndMeasureNextPaint(page, "x");
await expect(composer).toHaveValue("alpha\nbeta\nx");
expect(filledTrailingLineHeight).toBe(trailingLineHeight);
});
} finally {
await agent.cleanup();
}
});
test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }) => {
test.setTimeout(90_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-bottom-anchor-",
title: "Composer bottom anchor",
initialPrompt: "Produce enough content to make the chat scrollable.",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
await scrollAgentChatToBottom(page);
const composer = composerLocator(page);
await composer.fill("alpha");
for (let line = 0; line < 8; line += 1) {
await composer.press("Shift+Enter");
}
await expectNearBottom(page);
} finally {
await agent.cleanup();
}
});
@@ -1,5 +0,0 @@
/** Successful Canvas2D POC run at 6× Chromium CPU slowdown on the checked-in fixture. */
export const DIFF_POC_BASELINE = {
medianFrameMs: 25,
p95FrameMs: 50,
} as const;
@@ -1,587 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT_PER_FILE = 1_200;
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const POC_MEDIAN_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_MEDIAN_FRAME_MS ?? DIFF_POC_BASELINE.medianFrameMs,
);
const POC_P95_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
);
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
diffPerfDescribe("Diff canvas performance", () => {
test("opening a review stays responsive in a large diff", async ({ page }) => {
test.setTimeout(120_000);
const repo = await createLargeDiffRepository();
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create diff performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
const elapsedMs = await openFirstReviewEditor(page);
expect(elapsedMs).toBeLessThanOrEqual(200 * CPU_SLOWDOWN);
} finally {
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const repo = await createLargeDiffRepository();
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
let traceStarted = false;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create diff performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
await startChromiumTrace(cdp);
traceStarted = true;
await startRendererInstrumentation(page);
const scrollReports = [];
const heapUsage = [];
for (let cycle = 0; cycle < 3; cycle += 1) {
scrollReports.push(await continuouslyScrollCanvas(page));
heapUsage.push(await collectHeapUsage(cdp));
}
await expectContinuousFramesMatchStableRender(
page,
scrollReports.flatMap((report) => report.frameSignatures),
);
const frameImages = await captureCanvasAtScrollIntervals(page);
for (const [frameIndex, frame] of frameImages.entries()) {
await writeFile(testInfo.outputPath(`diff-canvas-frame-${frameIndex}.png`), frame.image);
await testInfo.attach(`diff-canvas-frame-${frameIndex}`, {
body: frame.image,
contentType: "image/png",
});
expect(frame.image.byteLength).toBeGreaterThan(4_000);
}
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
const renderer = await readRendererInstrumentation(page);
const measuredCycles = scrollReports.slice(1);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
MAX_P95_FRAME_MS,
);
for (const cycle of scrollReports) {
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
await reloadDiffPresentation(page, { layout: "split", wrapLines: false });
const split = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "split");
const report = {
cpuSlowdown: CPU_SLOWDOWN,
pocBaseline: {
medianFrameMs: POC_MEDIAN_FRAME_MS,
p95FrameMs: POC_P95_FRAME_MS,
},
sourceFiles: FILE_PATHS.length,
sourceLines: FILE_PATHS.length * LINE_COUNT_PER_FILE,
scrollReports,
heapUsage,
renderer,
wrapped,
split,
canvasPngBytes: frameImages.map((frame) => frame.image.byteLength),
};
await attachPerformanceReport(testInfo, report);
await stopChromiumTrace(cdp, testInfo);
traceStarted = false;
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
} finally {
if (cdp && traceStarted) {
await stopChromiumTrace(cdp, testInfo).catch(() => undefined);
}
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
});
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
testInfo: TestInfo,
name: "wrapped" | "split",
) {
await expectSingleCanvasSurface(page);
await startRendererInstrumentation(page);
const scrollReports = [];
const heapUsage = [];
for (let cycle = 0; cycle < 3; cycle += 1) {
scrollReports.push(await continuouslyScrollCanvas(page));
heapUsage.push(await collectHeapUsage(cdp));
}
await expectContinuousFramesMatchStableRender(
page,
scrollReports.flatMap((report) => report.frameSignatures),
);
const frames = await captureCanvasAtScrollIntervals(page);
for (const [index, frame] of frames.entries()) {
await writeFile(testInfo.outputPath(`diff-canvas-${name}-frame-${index}.png`), frame.image);
await testInfo.attach(`diff-canvas-${name}-frame-${index}`, {
body: frame.image,
contentType: "image/png",
});
expect(frame.image.byteLength).toBeGreaterThan(4_000);
}
const measuredCycles = scrollReports.slice(1);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
MAX_P95_FRAME_MS,
);
for (const cycle of scrollReports) {
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
const renderer = await readRendererInstrumentation(page);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
return {
scrollReports,
heapUsage,
renderer,
canvasPngBytes: frames.map((frame) => frame.image.byteLength),
};
}
async function reloadDiffPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
await page.evaluate(
({ preferencesKey, presentation }) => {
const current = JSON.parse(localStorage.getItem(preferencesKey) ?? "{}") as object;
localStorage.setItem(preferencesKey, JSON.stringify({ ...current, ...presentation }));
},
{ preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation },
);
await page.reload();
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
}
async function createLargeDiffRepository() {
const repo = await createTempGitRepo("diff-perf-", {
files: FILE_PATHS.map((filePath, fileIndex) => ({
path: filePath,
content: `export const baseline_${fileIndex} = 0;\n`,
})),
});
for (const [fileIndex, filePath] of FILE_PATHS.entries()) {
const generatedLines = Array.from(
{ length: LINE_COUNT_PER_FILE },
(_, lineIndex) =>
`export const generated_${fileIndex}_${lineIndex} = "${lineIndex}-${"wide".repeat(16)}";`,
).join("\n");
await writeFile(
path.join(repo.path, filePath),
`export const baseline_${fileIndex} = 1;\n${generatedLines}\n`,
);
}
return repo;
}
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
};
scope.__PASEO_DIFF_REACT_STATS__ = { commits: 0 };
if (!localStorage.getItem(preferencesKey)) {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
}
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function openFirstReviewEditor(page: Page): Promise<number> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const startedAt = performance.now();
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
return performance.now() - startedAt;
}
async function expectSingleCanvasSurface(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(1);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page
.getByTestId("git-diff-canvas-root")
.evaluate((root) => root.querySelectorAll("*").length);
expect(elementCount).toBeLessThan(150);
}
async function continuouslyScrollCanvas(page: Page): Promise<{
medianFrameMs: number;
p95FrameMs: number;
maximumFrameMs: number;
slowFrames: number;
endScrollTop: number;
maximumScrollTop: number;
frameSignatures: Array<{ scrollTop: number; signature: number }>;
}> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
scroll.scrollTop = 0;
const frameDurations: number[] = [];
const frameSignatures: Array<{ scrollTop: number; signature: number }> = [];
let previousFrame = performance.now();
for (let step = 1; step <= 90; step += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const now = performance.now();
frameDurations.push(now - previousFrame);
previousFrame = now;
scroll.scrollTop = (maximumScrollTop * step) / 90;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
if (step % 10 === 0) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="git-diff-canvas"]',
)!;
frameSignatures.push({
scrollTop: scroll.scrollTop,
signature: canvasSignature(canvas, scroll),
});
previousFrame = performance.now();
}
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const ordered = [...frameDurations].sort((left, right) => left - right);
const percentile = (fraction: number) =>
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
return {
medianFrameMs: percentile(0.5),
p95FrameMs: percentile(0.95),
maximumFrameMs: Math.max(...frameDurations),
slowFrames: frameDurations.filter((duration) => duration > 50).length,
endScrollTop: scroll.scrollTop,
maximumScrollTop,
frameSignatures,
};
function canvasSignature(canvasElement: HTMLCanvasElement, scrollElement: HTMLElement): number {
const sample = document.createElement("canvas");
sample.width = 64;
sample.height = 36;
const context = sample.getContext("2d")!;
const canvasCssHeight = Number.parseFloat(canvasElement.style.height);
const canvasTop = Number.parseFloat(canvasElement.style.top);
const pixelRatio = canvasElement.height / canvasCssHeight;
const sourceY = (scrollElement.scrollTop - canvasTop) * pixelRatio;
context.drawImage(
canvasElement,
0,
sourceY,
canvasElement.width,
scrollElement.clientHeight * pixelRatio,
0,
0,
sample.width,
sample.height,
);
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return hash >>> 0;
}
});
}
interface CapturedCanvasFrame {
scrollTop: number;
image: Buffer;
}
async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanvasFrame[]> {
const scroll = page.getByTestId("git-diff-scroll");
const frames: CapturedCanvasFrame[] = [];
for (let step = 0; step <= 8; step += 1) {
const scrollTop = await scroll.evaluate(async (element, progress) => {
const scrollElement = element as HTMLElement;
scrollElement.scrollTop =
(scrollElement.scrollHeight - scrollElement.clientHeight) * Number(progress);
scrollElement.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return scrollElement.scrollTop;
}, step / 8);
frames.push({ scrollTop, image: await scroll.screenshot() });
}
return frames;
}
async function expectContinuousFramesMatchStableRender(
page: Page,
samples: Array<{ scrollTop: number; signature: number }>,
): Promise<void> {
const stable = await page.getByTestId("git-diff-scroll").evaluate(async (element, frames) => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="git-diff-canvas"]')!;
const signatures: number[] = [];
for (const frame of frames) {
element.scrollTop = frame.scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const sample = document.createElement("canvas");
sample.width = 64;
sample.height = 36;
const context = sample.getContext("2d")!;
drawVisibleCanvas(context, canvas, element as HTMLElement, sample.width, sample.height);
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
signatures.push(hash >>> 0);
}
return signatures;
function drawVisibleCanvas(
context: CanvasRenderingContext2D,
canvasElement: HTMLCanvasElement,
scroll: HTMLElement,
width: number,
height: number,
): void {
const canvasCssHeight = Number.parseFloat(canvasElement.style.height);
const canvasTop = Number.parseFloat(canvasElement.style.top);
const pixelRatio = canvasElement.height / canvasCssHeight;
const sourceY = (scroll.scrollTop - canvasTop) * pixelRatio;
context.drawImage(
canvasElement,
0,
sourceY,
canvasElement.width,
scroll.clientHeight * pixelRatio,
0,
0,
width,
height,
);
}
}, samples);
expect(stable).toEqual(samples.map((sample) => sample.signature));
}
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
page: Page,
): Promise<void> {
const first = page.getByTestId("diff-file-0-horizontal-scroll");
const second = page.getByTestId("diff-file-1-horizontal-scroll");
const firstOffset = await setHorizontalOffset(first, 240);
const secondOffset = await setHorizontalOffset(second, 120);
expect(firstOffset).not.toBe(secondOffset);
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
}
async function setHorizontalOffset(locator: Locator, offset: number) {
return locator.evaluate(async (element, nextOffset) => {
element.scrollLeft = nextOffset;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return element.scrollLeft;
}, offset);
}
async function startRendererInstrumentation(page: Page): Promise<void> {
await page.getByTestId("git-diff-canvas-root").evaluate((root) => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
__PASEO_DIFF_RENDERER_STATS__?: {
initialCommits: number;
addedElements: number;
removedElements: number;
};
};
const reactStats = scope.__PASEO_DIFF_REACT_STATS__ ?? { commits: 0, unmounts: 0 };
const stats = {
initialCommits: reactStats.commits,
addedElements: 0,
removedElements: 0,
};
scope.__PASEO_DIFF_RENDERER_STATS__ = stats;
new MutationObserver((records) => {
for (const record of records) {
stats.addedElements += Array.from(record.addedNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
).length;
stats.removedElements += Array.from(record.removedNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
).length;
}
}).observe(root, { childList: true, subtree: true });
});
}
async function readRendererInstrumentation(page: Page): Promise<{
reactCommits: number;
addedElements: number;
removedElements: number;
}> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
__PASEO_DIFF_RENDERER_STATS__?: {
initialCommits: number;
addedElements: number;
removedElements: number;
};
};
const react = scope.__PASEO_DIFF_REACT_STATS__!;
const renderer = scope.__PASEO_DIFF_RENDERER_STATS__!;
return {
reactCommits: react.commits - renderer.initialCommits,
addedElements: renderer.addedElements,
removedElements: renderer.removedElements,
};
});
}
async function collectHeapUsage(cdp: CDPSession): Promise<{ usedSize: number; totalSize: number }> {
await cdp.send("HeapProfiler.collectGarbage");
return cdp.send("Runtime.getHeapUsage");
}
async function startChromiumTrace(cdp: CDPSession): Promise<void> {
await cdp.send("Tracing.start", {
categories: [
"devtools.timeline",
"v8.execute",
"blink.user_timing",
"disabled-by-default-v8.cpu_profiler",
].join(","),
transferMode: "ReturnAsStream",
});
}
async function stopChromiumTrace(cdp: CDPSession, testInfo: TestInfo): Promise<void> {
const completed = new Promise<string>((resolve, reject) => {
cdp.once("Tracing.tracingComplete", ({ stream }) => {
if (stream) resolve(stream);
else reject(new Error("Chromium trace completed without a stream"));
});
});
await cdp.send("Tracing.end");
const stream = await completed;
const chunks: string[] = [];
let eof = false;
while (!eof) {
const chunk = await cdp.send("IO.read", { handle: stream });
chunks.push(chunk.data);
eof = chunk.eof;
}
await cdp.send("IO.close", { handle: stream });
const trace = chunks.join("");
await writeFile(testInfo.outputPath("diff-canvas-chromium-trace.json"), trace);
await testInfo.attach("diff-canvas-chromium-trace", {
body: trace,
contentType: "application/json",
});
}
async function attachPerformanceReport(
testInfo: TestInfo,
report: Record<string, unknown>,
): Promise<void> {
await testInfo.attach("diff-canvas-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
}
@@ -1,33 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const focusedPane = page
.getByTestId("workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
const primaryPane = page
.getByTestId("workspace-tabs-row")
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
.first();
await pressDirectNewTabShortcut(page, "t");
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
timeout: 30_000,
});
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
});
@@ -1,231 +0,0 @@
// Locks two behaviours that used to break together on web/desktop:
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
// MAIN pane in the background — the explorer pane is a background surface and must
// never swallow entity tabs or lose the user's focus, and
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an
// app-wide lockup here with no way to close the empty pane).
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
}
function agentTabChip(page: Page, agentId: string): Locator {
return visible(page, `workspace-tab-agent_${agentId}`);
}
function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** The explorer pane is the only tab row carrying the Changes tab. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
}
function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
hasNot: page.getByTestId("workspace-tab-working_diff"),
});
}
async function focusExplorerPane(page: Page): Promise<void> {
await ensureExplorerPane(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await changesTab.click({ position: { x: 12, y: 13 } });
await expect(changesTab).toHaveAttribute("aria-selected", "true");
}
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
const chip = draftTabChip(page).first();
await chip.hover();
await page
.locator('[data-testid^="workspace-draft-close-"]')
.filter({ visible: true })
.first()
.click();
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
async function emptyPaneBox(page: Page) {
const paneChild = page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.first();
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Empty pane has no bounding box");
return box;
}
async function paneBoxContainingChip(page: Page, chip: Locator) {
const paneChild = page.getByTestId("split-group-child").filter({ has: chip }).first();
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Pane has no bounding box");
return box;
}
/** dnd-kit pointer drag from a tab chip to an absolute point. */
async function dragChipTo(
page: Page,
chip: Locator,
target: { x: number; y: number },
): Promise<void> {
const chipBox = await chip.boundingBox();
expect(chipBox).not.toBeNull();
if (!chipBox) throw new Error("Dragged chip has no bounding box");
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
await page.mouse.down();
// Exceed the 8px PointerSensor activation distance before heading to the target.
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
await page.mouse.move(target.x, target.y, { steps: 20 });
await page.mouse.up();
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
page,
}, testInfo) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
let agentId = "";
try {
await test.step("empty workspace seeds a draft in the main pane", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("focus the explorer pane", async () => {
await focusExplorerPane(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not steal the explorer pane's focus.
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
"aria-selected",
"true",
);
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(1);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything.
const emptyPane = page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.first();
await emptyPane.click({ timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
await emptyPaneTrigger.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
// A second drag must work: center-drop the agent tab back into the empty pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
});
});
@@ -1,130 +0,0 @@
// Reported on 0.5.0-beta.1 desktop: after upgrading, the old docked explorer
// sidebar and the new explorer pane were both visible, and on Windows the
// sidebar had no close button because the window controls own that corner.
//
// The sidebar rendered off a persisted `desktop.fileExplorerOpen` flag that no
// migration cleared and that the rerouted explorer toggle no longer wrote. The
// explorer is now one surface per layout, so that flag must not resurrect it.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
}
function readPersistedPanelState(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem("panel-state"));
}
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
function dockedSidebar(page: Page): Locator {
return visible(page, "explorer-header");
}
/** The explorer pane is the tab row carrying the Changes tab. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
async function seedRetiredSidebarState(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({
version: 14,
state: {
desktop: { agentListOpen: true, fileExplorerOpen: true, focusModeEnabled: false },
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
collapsedFilePathsByWorkspace: {},
sidebarWidth: 340,
explorerWidth: 400,
explorerSortOption: "name",
explorerShowHiddenFiles: true,
treeRailWidth: 320,
},
}),
);
});
}
test.describe("explorer surface after upgrading from the docked sidebar", () => {
test("a persisted fileExplorerOpen flag no longer renders a second explorer", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-upgrade-" });
try {
await seedRetiredSidebarState(page);
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await test.step("no docked sidebar, on load or after toggling", async () => {
await expect(dockedSidebar(page)).toHaveCount(0);
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
});
await test.step("the retired keys are migrated out of storage", async () => {
const persisted = await readPersistedPanelState(page);
expect(persisted).not.toBeNull();
expect(persisted).not.toContain("fileExplorerOpen");
expect(persisted).not.toContain("explorerWidth");
// Everything else the user had must survive the migration.
expect(persisted).toContain('"sidebarWidth":340');
});
} finally {
await workspace.cleanup();
}
});
test("toggle-both-sidebars closes and restores the agent list and the explorer", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-both-" });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const agentList = visible(page, "sidebar-sessions");
const toggle = visible(page, "workspace-explorer-toggle").first();
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
await expect(agentList).toHaveCount(1);
});
await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0);
});
await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
});
} finally {
await workspace.cleanup();
}
});
});
+14 -10
View File
@@ -38,7 +38,10 @@ function fitsViewportWidth(element: HTMLElement): boolean {
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
await editor(page).fill(content);
const contentElement = editor(page);
await contentElement.click();
await contentElement.press("Control+A");
await contentElement.type(content);
}
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
@@ -151,8 +154,9 @@ test.describe("CodeMirror workspace file editing", () => {
await page.setViewportSize({ width: 1280, height: 900 });
await openAgentRoute(page, session);
await openWorkspaceFile(page, "target.ts");
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await openWorkspaceFile(page, "target.ts");
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
@@ -343,13 +347,14 @@ test.describe("CodeMirror workspace file editing", () => {
await replaceEditorText(page, "const localWins = 5;\n");
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
await openWorkspaceFile(page, fileName);
}
await openWorkspaceFile(page, "source.ts");
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
await expect(editor(page)).toContainText("const localWins = 5;");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
await replaceEditorText(page, "const discarded = 7;\n");
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
@@ -436,21 +441,20 @@ test.describe("CodeMirror workspace file editing", () => {
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
const image = page.getByTestId("workspace-file-pane").locator("img");
await expect(image).toBeVisible();
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
@@ -3,7 +3,6 @@ import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -111,21 +110,6 @@ test.afterEach(async () => {
await workspace?.cleanup();
});
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const row = page
.getByTestId("file-explorer-tree-scroll")
.getByTestId(/^file-explorer-row-\d+$/)
.first();
const name = row.getByTestId(/-name$/);
await expect(row).toHaveCSS("opacity", "1");
await expect(name).toHaveCSS("opacity", "0.76");
await row.hover();
await expect(name).toHaveCSS("opacity", "1");
});
test("creates, renames, copies, and deletes entries through the file explorer", async ({
context,
page,
@@ -453,7 +437,7 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await openChangesPanel(page);
await page.getByTestId("explorer-tab-changes").click();
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
@@ -1,60 +0,0 @@
import { test, expect } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectExplorerNodeRetained,
hideExplorerSplit,
measureExplorerSplit,
openExplorerSplit,
resizeExplorerSplit,
stampExplorerNode,
} from "../support/helpers/split-pane";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
test.describe("Hidden split pane retention", () => {
test("releases all layout space without unmounting and restores its dragged width", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await openExplorerSplit(page);
// Drag to a non-default width first: restoring the default would prove nothing.
await resizeExplorerSplit(page, 137);
await stampExplorerNode(page);
const open = await measureExplorerSplit(page);
expect(open.explorerWidth).toBeGreaterThan(250);
expect(open.separatorCount).toBe(1);
await hideExplorerSplit(page);
const hidden = await measureExplorerSplit(page);
expect(hidden.explorerWidth).toBe(0);
// The sibling must absorb the whole row, not just collapse the hidden pane.
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.separatorCount).toBe(0);
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
await expectExplorerNodeRetained(page);
await openExplorerSplit(page);
await expectExplorerNodeRetained(page);
const reopened = await measureExplorerSplit(page);
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
expect(reopened.separatorCount).toBe(1);
await testInfo.attach("split-pane-geometry", {
body: JSON.stringify({ open, hidden, reopened }, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
});
});
+10 -154
View File
@@ -4,18 +4,14 @@ import {
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
pressNewTabShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
waitForTabBar,
pressNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
expectTabTitleFits,
terminalSurfaceLocator,
} from "../support/helpers/launcher";
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
@@ -24,37 +20,13 @@ import {
setupDeterministicPrompt,
} from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalOutputContains,
seedTerminalProfiles,
type TerminalProfile,
} from "../support/helpers/new-workspace-launch";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
// ─── Shared state ──────────────────────────────────────────────────────────
let workspace: SeededWorkspace;
let secondWorkspaceId: string | null = null;
const EMPTY_PROMPT_PROFILE: TerminalProfile = {
id: "e2e-empty-prompt",
name: "Empty Prompt",
command: "/bin/sh",
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
};
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
source: { kind: "directory", path: workspace.repoPath, projectId: workspace.projectId },
title: "launcher-e2e-cmd-t-switch-target",
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create secondary workspace for cmd+t switch test");
}
secondWorkspaceId = created.workspace.id;
});
test.afterAll(async () => {
@@ -66,66 +38,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
// The shortcut must follow the route, so prove the route actually moved first.
await expect(page).toHaveURL(new RegExp(`workspace/${workspaceId}(\\b|/|$)`), {
timeout: 15_000,
});
await waitForTabBar(page);
};
await gotoAppShell(page);
await waitForSidebarHydration(page);
const sequence = [workspace.workspaceId, secondWorkspaceId];
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
}
});
test("retained inactive workspaces cannot own New tab shortcuts", async ({ page }) => {
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await pressNewTabShortcut(page);
await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
});
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
await expectComposerVisible(page);
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
@@ -133,45 +51,20 @@ test.describe("Tab creation", () => {
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
// Blur the composer so the second shortcut isn't swallowed by the focused input
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
});
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
const changes = page.getByRole("menuitem", { name: /Changes/ });
const files = page.getByRole("menuitem", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
"aria-expanded",
"false",
);
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
@@ -197,45 +90,12 @@ test.describe("Tab creation", () => {
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("launching a profile from the New tab menu drops its empty prompt argument", async ({
page,
}) => {
test.setTimeout(45_000);
const profileSeed = await seedTerminalProfiles([EMPTY_PROMPT_PROFILE]);
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
await profileSeed.restore();
}
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
test("never shades the New tab button", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -337,7 +197,7 @@ test.describe("Tab transitions (no flash)", () => {
await gotoWorkspace(page, workspace.workspaceId);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
@@ -347,15 +207,11 @@ test.describe("Tab transitions (no flash)", () => {
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
const counts = snapshots.map((snapshot) => snapshot.length);
const counts = snapshots.map((s) => s.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
2,
);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
@@ -34,8 +34,6 @@ const STREAMED_MERMAID = [
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
title: "Streaming Mermaid",
@@ -1,4 +1,4 @@
import { expect, test } from "../support/fixtures";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
@@ -85,52 +85,4 @@ test.describe("New workspace composer draft", () => {
await project.cleanup();
}
});
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
});
const composer = page.getByRole("textbox", { name: "Message agent..." });
const createButton = page.getByTestId("workspace-create-submit");
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect(createButton).toBeEnabled({ timeout: 30_000 });
await composer.evaluate((element, draft) => {
if (!(element instanceof HTMLTextAreaElement)) {
throw new Error("Composer input is not a textarea");
}
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
if (!valueSetter) throw new Error("Textarea value setter is unavailable");
valueSetter.call(element, draft);
element.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: draft,
inputType: "insertText",
}),
);
const button = document.querySelector('[data-testid="workspace-create-submit"]');
if (!(button instanceof HTMLElement)) {
throw new Error("Create button is unavailable");
}
button.click();
}, DRAFT);
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceDraft(page, "");
} finally {
await project.cleanup();
}
});
});
@@ -1,10 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => {
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await dictateAndSend(page, harness.waitForAudio);
await harness.waitForCreateRequest();
@@ -4,6 +4,8 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
expectWorkspaceRuntimeChoices,
expectWorkspaceRuntimeSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
@@ -29,7 +31,7 @@ import {
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
// screen, and non-git projects offer only the Local runtime.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
@@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => {
sourceDirectory: "/tmp/missing-project",
});
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
test("the project shortcut opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
@@ -246,9 +249,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
page,
}) => {
test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
const nonGitProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "entry-iso-nongit-",
@@ -274,11 +275,10 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local"]);
// Switching to the git project on the same screen reveals the Isolation row.
// Switching to the git project on the same screen adds Worktree.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
@@ -287,9 +287,8 @@ test.describe("New workspace entry points", () => {
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
@@ -1,29 +1,24 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
expectWorkspaceRuntimeSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
} from "../support/helpers/new-workspace";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
} from "../support/helpers/new-workspace-runtime";
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
// (FormPreferences.isolation), so it must survive the create→reopen remount:
// creating a worktree workspace navigates away from /new and unmounts it, and
// reopening New Workspace has to still show "New worktree".
test.describe("New workspace isolation memory", () => {
// Regression for "the Local / Worktree runtime selection in New Workspace is not
// remembered." The runtime choice persists in the create-form preferences, so it
// must survive the create→reopen remount: creating a Worktree workspace
// navigates away from /new and unmounts it.
test.describe("New workspace runtime memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
@@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => {
});
test.afterEach(async () => {
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
const project = await seedGitProjectForRuntime();
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
// First visit: the screen opens on Local, switch it to New worktree and create.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
// First visit: the screen opens on Local, switch it to Worktree and create.
await gotoNewWorkspaceForRuntime(page, project);
await expectWorkspaceRuntimeSelected(page, "local");
await page.getByRole("button", { name: "Runtime", exact: true }).click();
const runtimePopup = page.getByRole("dialog").last();
await expect(runtimePopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(runtimePopup);
await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click();
await expectWorkspaceRuntimeSelected(page, "worktree");
const createButton = page
.getByTestId("message-input-root")
@@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => {
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick.
// Second visit (fresh mount of /new): the runtime choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "worktree");
await expectWorkspaceRuntimeSelected(page, "worktree");
} finally {
await tempRepo.cleanup();
await project.cleanup();
}
});
});
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
// Loaded CI workers can take several seconds to materialize the runtime and attach the UI.
// Keep the fixture bounded but alive through that assertion window.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -1,16 +1,17 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
type SeededRuntimeProject,
} from "../support/helpers/new-workspace-runtime";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const LONG_HOST_NAME =
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
@@ -27,17 +28,14 @@ function measureControlRightEdges(controls: HTMLElement[]) {
}
test.describe("New workspace metadata row layout", () => {
let workspace: SeededWorkspace;
let project: SeededRuntimeProject;
test.beforeEach(async () => {
workspace = await seedWorkspace({
repoPrefix: "new-workspace-layout-",
repo: { branches: [LONG_BRANCH_NAME] },
});
project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
});
test.afterEach(async () => {
await workspace?.cleanup();
await project?.cleanup();
});
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
@@ -55,13 +53,8 @@ test.describe("New workspace metadata row layout", () => {
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await gotoNewWorkspaceForRuntime(page, project);
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, LONG_BRANCH_NAME);
@@ -32,7 +32,7 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
@@ -771,7 +771,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
return openedProject;
}
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
@@ -996,7 +996,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "main");
@@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await pasteGithubPrUrl(page, context, pr.url);
await expectStartingRefPickerTriggerPr(page, {
number: pr.number,
@@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
await submitNewWorkspaceWithoutPrompt(page);
@@ -1,247 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
} from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "workspace-panel-e2e";
const WIDE_VIEWPORT = { width: 1280, height: 900 };
const COMPACT_VIEWPORT = { width: 390, height: 844 };
function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginSource(): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const renderCount = useRef(0);
renderCount.current += 1;
return <View><Text>Workspace bridge {workspace?.id}</Text><Text>Workspace renders {renderCount.current}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function AgentPanel({ workspaceId, agentId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const agent = useAgent(agentId, (value) => ({ id: value.id }));
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function DirectCollisionSurface() {
return <View><Text>Direct collision surface</Text></View>;
}
function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
export default function contribute(plugin) {
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
return () => {};
}`;
}
async function searchCommands(page: Page, query: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
return panel;
}
async function expectCommandAvailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
async function expectCommandUnavailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin workspace panels and Command Center", () => {
test.describe.configure({ timeout: 240_000 });
test("follows workspace, agent, host, compact, and unavailable state", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-workspace-panel-e2e-"));
const primaryClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await primaryClient.getDaemonConfig();
const primary = await seedWorkspace({ repoPrefix: "plugin-panel-primary-" });
const secondaryDaemon = await startIsolatedHostDaemon("plugin-panel-secondary");
const secondary = await seedWorkspace({
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), pluginSource());
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
await primaryClient.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE_VIEWPORT);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryDaemon.serverId,
label: "Secondary plugin host",
port: secondaryDaemon.port,
primaryLabel: "Primary plugin host",
});
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await test.step("workspace context opens the real wide panel bridge", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await expectCommandAvailable(page, "Plugin global action");
await expectCommandAvailable(page, "Open plugin workspace");
await expectCommandUnavailable(page, "Open plugin agent");
await runCommand(page, "Open plugin workspace");
await expect(page.getByText(`Workspace bridge ${primary.workspaceId}`)).toBeVisible();
const renderCount = Number(
(await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1),
);
await primaryClient.setWorkspaceTitle(primary.workspaceId, "Unrelated title update");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Unrelated title update",
);
expect(
Number((await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1)),
).toBe(renderCount);
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout wide", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-workspace-panel-wide");
});
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toHaveCount(0);
await page.getByTestId("plugin-surface-close").click();
await page.getByTestId(`plugin-sidebar-${PLUGIN_ID}-collision`).click();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Direct collision surface", { exact: true })).toHaveCount(0);
await capture(page, testInfo, "plugin-surface-kind-collision");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await expectCommandUnavailable(page, "Plugin global action");
await expectCommandUnavailable(page, "Open plugin workspace");
});
await test.step("legacy sidebar URLs survive a full browser navigation", async () => {
await page.goto(`/h/${encodeURIComponent(getServerId())}/plugin/${PLUGIN_ID}/collision`);
await page.waitForURL(new RegExp(`/plugin/${PLUGIN_ID}/sidebar/collision(?:\\?.*)?$`));
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
});
await test.step("agent context opens the compact panel with synchronous snapshots", async () => {
const agent = await primary.client.createAgent({
provider: "mock",
cwd: primary.repoPath,
workspaceId: primary.workspaceId,
title: "Plugin panel context agent",
model: "ten-second-stream",
modeId: "load-test",
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await page.setViewportSize(COMPACT_VIEWPORT);
await openCompactSidebar(page);
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
page.getByText(`Workspace ${primary.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
});
await test.step("removing the active plugin renders the panel unavailable", async () => {
await primaryClient.removePlugin(PLUGIN_ID);
await expect(
page.getByText("This plugin panel is unavailable.", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "plugin-panel-unavailable");
await openCompactSidebar(page);
await expectCommandUnavailable(page, "Open plugin agent");
});
} finally {
await primaryClient.removePlugin(PLUGIN_ID).catch(() => undefined);
await primaryClient
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await primary.cleanup().catch(() => undefined);
await secondary.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -45,19 +45,12 @@ import {
unblockPaseoConfigWrites,
} from "../support/helpers/project-settings";
import { gotoAppShell } from "../support/helpers/app";
import { openCompactSettings } from "../support/helpers/settings";
import {
addProjectFlowInput,
chooseAddProjectMethod,
openAddProjectFlow,
} from "../support/helpers/add-project-flow";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
buildOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsRoute,
} from "@/utils/host-routes";
import { getServerId } from "../support/helpers/server-id";
const updatedSetup = ["npm install", "npm run build"];
@@ -328,33 +321,6 @@ test.describe("Projects settings", () => {
});
});
test.describe("Projects settings — compact navigation", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("project Back returns through Projects and Settings without reopening the project", async ({
page,
editableProject,
}) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
await openProjectSettings(page, editableProject.name);
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Back to projects", exact: true })).toHaveCount(
0,
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(buildSettingsRoute());
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
});
});
test.describe("Projects settings — error UX", () => {
test("stale-write callout appears on save, disables save, and reload clears it", async ({
page,
@@ -21,22 +21,21 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
async function openProviderSettingsFromModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
const configuration = page.getByTestId("agent-controls-model-sheet");
await expect(configuration).toBeVisible({ timeout: 10_000 });
await page.getByTestId("agent-controls-model").click();
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
const modelBrowser = page.getByTestId("agent-controls-model-browser-sheet");
await expect(modelBrowser).toBeVisible({ timeout: 10_000 });
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
if (await openCodeRow.isVisible().catch(() => false)) {
await openCodeRow.click();
}
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
}
async function expectModelBrowserVisible(page: Page) {
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible({
async function expectModelSelectorVisible(page: Page) {
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible();
}
async function closeTopSheet(page: Page) {
@@ -181,7 +180,7 @@ test.describe("provider settings overlay stack", () => {
}
});
test("provider settings and children close back through the model browser to configuration", async ({
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
test.setTimeout(180_000);
@@ -193,18 +192,13 @@ test.describe("provider settings overlay stack", () => {
await exerciseProviderSettingsStack(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelBrowserVisible(page);
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelBrowserVisible(page);
await closeTopSheet(page);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expectModelSelectorVisible(page);
await closeTopSheet(page);
} finally {
await session.cleanup();
@@ -89,9 +89,6 @@ test.describe("real provider subagent timelines", () => {
}
await rows.first().click();
// Choosing a row is a menu selection: the track panel goes with it.
await expect(page.getByTestId("subagents-track-header-panel")).toBeHidden();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
if (scenario.expectedSubtitle) {
@@ -136,8 +133,6 @@ test.describe("real provider subagent timelines", () => {
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
// Opening the subagent's tab closed the panel with the parent's pane.
await openSubagentsTrack(page);
const archiveFinished = page.getByTestId("subagents-track-archive-finished");
await expect(archiveFinished).toBeVisible({ timeout: 30_000 });
await archiveFinished.click();
@@ -1,22 +1,28 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import { test, expect, type Page } from "../support/fixtures";
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { expectExplorerEntryVisible, openFileExplorer } from "../support/helpers/file-explorer";
import {
expectNoTerminalTabs,
clickFirstTerminalTab,
openChangesPanel,
} from "../support/helpers/workspace-tabs";
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs";
// Model B: two workspaces can back the SAME directory. What follows from that
// split is the contract these specs pin:
// - Files and Changes read the directory, so their content is IDENTICAL
// across same-directory workspaces.
// - The right sidebar (file browser / git changes) reads the directory, so it
// is IDENTICAL across same-directory workspaces.
// - Tabs (agents, terminals) are owned by the workspace, so they are
// INDEPENDENT across same-directory workspaces.
// On desktop the explorer is pinned open; on narrow layouts it must be toggled.
// Open it either way, then select the requested tab.
async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise<void> {
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
if (await openButton.isVisible().catch(() => false)) {
await openButton.click();
}
await page.getByTestId(`explorer-tab-${tab}`).click();
}
async function createSecondWorkspaceOnSameDir(
seeded: SeededWorkspace,
title: string,
@@ -35,7 +41,7 @@ async function createSecondWorkspaceOnSameDir(
test.describe("Same-directory workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("directory-backed Files and Changes content is shared across same-dir workspaces", async ({
test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" });
@@ -65,19 +71,19 @@ test.describe("Same-directory workspaces", () => {
// Workspace A: the new file shows in both the file browser and the git
// changes view.
await gotoWorkspace(page, seeded.workspaceId);
await openFileExplorer(page);
await openExplorerTab(page, "files");
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await openExplorerTab(page, "changes");
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
// Workspace B (same directory): the SAME directory-backed content is
// visible even though the panel tabs belong to a different workspace.
// Workspace B (same directory): the SAME change is visible. The right
// sidebar content does not differ between the two views.
await gotoWorkspace(page, secondWorkspaceId);
await openFileExplorer(page);
await openExplorerTab(page, "files");
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openChangesPanel(page);
await openExplorerTab(page, "changes");
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
@@ -12,12 +12,12 @@ test("Settings language selector switches General labels", async ({ page }) => {
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click();
await page.getByRole("button", { name: "简体中文 - Simplified Chinese", exact: true }).click();
await expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "简体中文", exact: true }).click();
await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click();
await page.getByRole("button", { name: "English - 英语", exact: true }).click();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});
@@ -30,12 +30,12 @@ test("Settings language selector switches to Korean", async ({ page }) => {
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "한국어 - Korean", exact: true }).click();
await page.getByRole("button", { name: "한국어 - Korean", exact: true }).click();
await expect(page.getByText("기본 전송", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "한국어", exact: true }).click();
await page.getByRole("menuitem", { name: "English - 영어", exact: true }).click();
await page.getByRole("button", { name: "English - 영어", exact: true }).click();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
});
@@ -2,7 +2,6 @@ import { test, expect } from "../support/fixtures";
import {
buildHostWorkspaceRoute,
buildOpenProjectRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
@@ -182,28 +181,6 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsSidebarVisible(page);
});
test("host picker settings opens Overview and backs through the settings list", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
await openWorkspace(page, workspace);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId("sidebar-hosts-trigger").click();
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "host"));
await expect(page.getByText("Overview", { exact: true })).toBeVisible();
await goBackInSettings(page);
await expectCompactSettingsList(page);
await goBackInSettings(page);
await expectAppRoute(page, workspaceRoute);
});
test("switching the host picker on the settings list scopes host rows without navigating", async ({
page,
}) => {
@@ -12,7 +12,7 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
async function expectSendBehavior(
page: import("@playwright/test").Page,
expected: "interrupt" | "queue" | "steer",
expected: "interrupt" | "queue",
) {
await expect
.poll(async () => {
@@ -1,39 +1,26 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
test.use({ viewport: { width: 1600, height: 900 } });
async function expectBorderHighlight(
page: Page,
testID: string,
hoverTarget: Locator,
expectedWidth: string,
) {
async function expectBorderHighlight(page: Page, testID: string) {
const handle = page.getByTestId(testID);
await expect(handle).toBeVisible();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
await hoverTarget.hover();
await handle.hover();
const highlight = page.getByTestId(`${testID}-highlight`);
await expect(highlight).toBeVisible();
await expect(highlight).toHaveCSS("width", expectedWidth);
await expect(highlight).toHaveCSS("width", "1px");
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
await workspace.navigateTo();
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
await expectBorderHighlight(page, "left-sidebar-resize-handle");
await page.getByTestId("workspace-explorer-toggle").first().click();
const splitHandle = page.getByTestId("workspace-split-resize-handle");
await expectBorderHighlight(
page,
"workspace-split-resize-handle",
splitHandle.getByRole("separator"),
"3px",
);
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
});
@@ -13,7 +13,6 @@ import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
@@ -332,7 +331,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
});
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
test("yields app navigation to the Explorer", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
try {
@@ -340,19 +339,44 @@ test.describe("Half-screen desktop layout", () => {
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
).toBeVisible();
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page.getByTestId("explorer-close")).toBeVisible();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox();
const headerGlyphBounds = await page
.getByTestId("menu-button")
.locator("svg")
.first()
.boundingBox();
const tabGlyphBounds = await page
.locator('[data-testid^="workspace-tab-"]')
.first()
.locator("svg")
.first()
.boundingBox();
expect(centerBounds).not.toBeNull();
expect(headerGlyphBounds).not.toBeNull();
expect(tabGlyphBounds).not.toBeNull();
expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo(
(tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0),
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect
.poll(
async () =>
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
)
.toBeGreaterThanOrEqual(400);
await page.getByTestId("explorer-close").click();
await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible();
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
} finally {
await workspace.cleanup();
}
@@ -1,62 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
__terminalAttachOverlaySeen?: boolean;
__terminalAttachOverlayObserver?: MutationObserver;
}
async function watchForTerminalAttachOverlay(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as AttachOverlayProbeWindow;
win.__terminalAttachOverlaySeen = false;
win.__terminalAttachOverlayObserver?.disconnect();
win.__terminalAttachOverlayObserver = new MutationObserver(() => {
if (document.querySelector('[data-testid="terminal-attach-loading"]')) {
win.__terminalAttachOverlaySeen = true;
}
});
win.__terminalAttachOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
});
});
}
async function terminalAttachOverlayWasSeen(page: Page): Promise<boolean> {
return page.evaluate(() => {
const win = window as AttachOverlayProbeWindow;
win.__terminalAttachOverlayObserver?.disconnect();
return win.__terminalAttachOverlaySeen === true;
});
}
test.describe("retained terminal tab streams", () => {
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-retained-tab-stream-" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test("switching back to a retained terminal does not reattach its stream", async ({ page }) => {
const first = await harness.createTerminal({ name: "retained-first" });
const second = await harness.createTerminal({ name: "retained-second" });
await harness.openTerminal(page, { terminalId: first.id });
const secondTab = page.getByTestId(`workspace-tab-terminal_${second.id}`).first();
await secondTab.click();
await expect(page.getByTestId("terminal-attach-loading")).toBeHidden({ timeout: 10_000 });
await watchForTerminalAttachOverlay(page);
await page.getByTestId(`workspace-tab-terminal_${first.id}`).first().click();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await page.waitForTimeout(100);
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
});
@@ -2,7 +2,6 @@ import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl";
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
interface TerminalSize {
rows: number | null;
@@ -83,7 +82,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
// Split the pane downward. This focuses the new empty pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
await page.getByRole("button", { name: "Split pane down" }).first().click();
// The local xterm renderer shrinks immediately on split - the part of the
// behaviour that already works and that makes the bug look like nothing changed.
@@ -56,10 +56,7 @@ async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSiz
}
async function createTerminalViaMenu(page: Page): Promise<void> {
// Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-trigger").click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
}
@@ -127,9 +124,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
).toBeVisible({
await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({
timeout: 30_000,
});
@@ -1,7 +1,6 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
@@ -65,8 +64,9 @@ async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let pauseServerMessages = false;
let secondRunningForwarded = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
@@ -80,14 +80,15 @@ async function gateSecondToolCall(page: Page, agentId: string) {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
if (!secondRunningMessage || secondRunningForwarded) {
return;
}
ws.send(secondRunningMessage);
secondRunningForwarded = true;
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
if (pauseServerMessages) {
return;
}
@@ -96,24 +97,41 @@ async function gateSecondToolCall(page: Page, agentId: string) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "running" || toolCall.status === "executing") {
return;
}
if (toolCall.status === "completed") {
// The mock tool completes inside the daemon's coalescing window, so the
// browser naturally receives its authoritative completed state first.
ws.send(message);
resolveFirstCompleted();
return;
}
}
secondCallId ??= toolCall.callId;
if (
toolCall.callId === secondCallId &&
(toolCall.status === "running" || toolCall.status === "executing")
) {
secondRunningMessage = message;
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
// Keep the second real tool call inspectably active. Production providers
// send this same status shape while their work remains in flight.
secondRunningMessage = replaceToolCallStatus(message, "running");
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
@@ -130,7 +148,9 @@ async function gateSecondToolCall(page: Page, agentId: string) {
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
if (secondRunningMessage) {
forwardSecondRunning?.();
}
},
};
}
@@ -176,11 +196,10 @@ test("measures an overview heading that becomes loading after its idle mount", a
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
const group = page.getByTestId("tool-call-group");
await expect(group).toBeVisible();
const idleGroupHandle = await group.elementHandle();
if (!idleGroupHandle) {
@@ -6,7 +6,6 @@ import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
expectAgentIdle,
expectInlineWorkingIndicator,
@@ -151,7 +150,7 @@ test.describe("Viewed agent timelines", () => {
try {
await enableMoveTabShortcut(page);
await openAgent(page, scenario, scenario.firstAgentId);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByRole("button", { name: "Split pane right" }).click();
await selectAgent(page, "Second viewed chat");
await moveActiveTabRight(page);
await expect(
@@ -3,10 +3,7 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
@@ -59,7 +56,7 @@ test.describe("Workspace agent title handoff", () => {
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
const prompt = "Keep the optimistic agent pane visible during handoff";
@@ -102,7 +99,7 @@ test.describe("Workspace agent title handoff", () => {
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff";
@@ -43,9 +43,7 @@ test("sidebar shortcuts exit focus mode and execute their action", async ({
await pressRightSidebarShortcut(page);
await expect(exitFocusModeButton(page)).toHaveCount(0);
await expect(
page.getByText("Changes", { exact: true }).filter({ visible: true }).first(),
).toBeVisible();
await expect(page.getByText("Changes", { exact: true })).toBeVisible();
});
test("sidebar shortcuts outside a workspace preserve its focus mode", async ({
@@ -1,383 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
interface SessionEnvelope {
type?: string;
message?: { type?: string; requestId?: string };
}
async function readWorkspaceLabels(seeded: Awaited<ReturnType<typeof seedWorkspace>>) {
const workspaces = await seeded.client.fetchWorkspaces();
for (const workspace of workspaces.entries) {
if (workspace.id === seeded.workspaceId) return workspace.labels?.slice().sort();
}
return undefined;
}
async function installWorkspaceLabelMutationFailure(page: import("@playwright/test").Page) {
let failNextAssignment = false;
let failNextUpdate = false;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
let envelope: SessionEnvelope | null = null;
try {
envelope = JSON.parse(raw) as SessionEnvelope;
} catch {
server.send(message);
return;
}
const request = envelope?.type === "session" ? envelope.message : null;
const failing =
(failNextAssignment && request?.type === "workspace.label.assignment.set.request") ||
(failNextUpdate && request?.type === "workspace.label.update.request");
if (failing && request?.type && request.requestId) {
failNextAssignment = false;
failNextUpdate = false;
browser.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: "Injected label mutation failure.",
code: "handler_error",
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
failNextAssignment() {
failNextAssignment = true;
},
failNextUpdate() {
failNextUpdate = true;
},
};
}
function labelRow(page: import("@playwright/test").Page, name: string) {
return page.getByTestId(`workspace-label-picker-row-${name}`);
}
async function box(locator: import("@playwright/test").Locator) {
const measured = await locator.boundingBox();
if (!measured) throw new Error("expected a visible element to measure");
return measured;
}
async function size(locator: import("@playwright/test").Locator) {
await settled(locator);
const { width, height } = await box(locator);
return { width, height };
}
/**
* Waits until an element stops moving.
*
* The flyout scales in and the sidebar behind it re-lays out as the filter changes, so a rail
* measured mid-animation is not a rail — and a measurement built from two `boundingBox()` calls
* would take its two halves from two different frames.
*/
async function settled(locator: import("@playwright/test").Locator) {
let previous = "";
await expect
.poll(async () => {
const current = JSON.stringify(await box(locator));
const stable = current === previous;
previous = current;
return stable;
})
.toBe(true);
}
async function expectAssigned(
page: import("@playwright/test").Page,
name: string,
assigned: boolean,
) {
await expect(labelRow(page, name)).toHaveAttribute("aria-checked", String(assigned));
}
async function openWorkspaceLabels(page: import("@playwright/test").Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
await page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`).first().click();
await page.getByTestId(`sidebar-workspace-menu-labels-${serverId}:${workspaceId}`).click();
await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible();
}
/**
* Creating is its own page: the list is replaced by a name field and the swatches, and one row
* commits. Picking a colour must not create anything on its own.
*/
async function createLabel(
page: import("@playwright/test").Page,
input: { workspaceId: string; name: string; color: string },
) {
await openWorkspaceLabels(page, input.workspaceId);
await page.getByTestId("workspace-label-picker-create").click();
await expect(page.getByTestId("workspace-label-picker-create-name")).toBeVisible();
await page.getByTestId("workspace-label-picker-create-name").fill(input.name);
await page.getByTestId(`workspace-label-swatch-${input.color}`).click();
await expect(page.getByTestId(`workspace-label-picker-row-${input.name}`)).toBeHidden();
await page.getByTestId("workspace-label-picker-create-submit").click();
await expectAssigned(page, input.name, true);
await page.keyboard.press("Escape");
}
test.describe("Workspace labels", () => {
test.describe.configure({ timeout: 180_000 });
test("keeps label filters reachable when no workspaces match", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-no-matches-" });
try {
await seeded.client.setWorkspaceLabel({
workspaceId: seeded.workspaceId,
label: { name: "Unused", color: "red" },
assigned: true,
});
await seeded.client.setWorkspaceLabel({
workspaceId: seeded.workspaceId,
label: { name: "Unused", color: "red" },
assigned: false,
});
await gotoAppShell(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-label-filter").click();
// Clear only exists once something is filtered, so the row has to be selected first.
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
await page.getByTestId("sidebar-label-filter-option-Unused").click();
await expect(page.getByText("No workspaces match", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-project-empty-state")).toBeHidden();
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
// Emptying the sidebar swaps the list's body and nothing above it, so the page the filter
// was set on is still up over the empty state and clearing it is one press away.
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeVisible();
await page.getByTestId("sidebar-label-filter-clear").click();
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible();
} finally {
await seeded.cleanup();
}
});
test("creates, multi-assigns, filters, groups, and edits against the daemon", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-" });
const unlabelled = await seedWorkspace({ repoPrefix: "workspace-labels-unlabelled-" });
try {
const mutationFailure = await installWorkspaceLabelMutationFailure(page);
await gotoAppShell(page);
await createLabel(page, { workspaceId: seeded.workspaceId, name: "Urgent", color: "red" });
await createLabel(page, {
workspaceId: seeded.workspaceId,
name: "Frontend",
color: "sky",
});
await test.step("every press toggles and the picker stays open", async () => {
await openWorkspaceLabels(page, seeded.workspaceId);
await labelRow(page, "Urgent").click();
await expectAssigned(page, "Urgent", false);
await labelRow(page, "Frontend").click();
await expectAssigned(page, "Frontend", false);
await labelRow(page, "Urgent").click();
await labelRow(page, "Frontend").click();
await expectAssigned(page, "Urgent", true);
await expectAssigned(page, "Frontend", true);
// Four toggles in, the page it started on is still the page it is on.
await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible();
await page.keyboard.press("Escape");
await expect.poll(readWorkspaceLabels.bind(null, seeded)).toEqual(["Frontend", "Urgent"]);
});
await test.step("a rejected assignment leaves the row alone and says why", async () => {
await openWorkspaceLabels(page, seeded.workspaceId);
mutationFailure.failNextAssignment();
await labelRow(page, "Urgent").click();
await expect(page.getByTestId("workspace-label-picker-error")).toContainText(
"Injected label mutation failure.",
);
await expectAssigned(page, "Urgent", true);
await page.keyboard.press("Escape");
});
await test.step("a label row filters on one press and clears on the next", async () => {
const labelledRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
);
const unlabelledRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${unlabelled.workspaceId}`,
);
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-label-filter").click();
const urgent = page.getByTestId("sidebar-label-filter-option-Urgent");
const frontend = page.getByTestId("sidebar-label-filter-option-Frontend");
const unlabelledLabel = page.getByTestId("sidebar-label-filter-option-unlabelled");
// A row is one control, so the only thing on it that could move is the check the engine
// draws — measured before anything is filtered, and again once a row carries one.
const atRest = await size(urgent);
// One press in, one press out, and the row is the whole target either way.
await urgent.click();
await expect(urgent).toHaveAttribute("aria-checked", "true");
await expect(labelledRow).toBeVisible();
await expect(unlabelledRow).toBeHidden();
expect(await size(urgent)).toEqual(atRest);
await urgent.click();
await expect(urgent).toHaveAttribute("aria-checked", "false");
await expect(labelledRow).toBeVisible();
await expect(unlabelledRow).toBeVisible();
expect(await size(urgent)).toEqual(atRest);
// Unlabelled is a first-class filter option.
await unlabelledLabel.click();
await expect(labelledRow).toBeHidden();
await expect(unlabelledRow).toBeVisible();
// Selection is deliberately OR-only: labels and Unlabelled together include both rows.
await urgent.click();
await expect(labelledRow).toBeVisible();
await expect(unlabelledRow).toBeVisible();
// Clear restores the complete sidebar without leaving the filter page.
await page.getByTestId("sidebar-label-filter-clear").click();
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
await expect(labelledRow).toBeVisible();
await expect(unlabelledRow).toBeVisible();
await expect(urgent).toHaveAttribute("aria-checked", "false");
await expect(frontend).toHaveAttribute("aria-checked", "false");
await expect(unlabelledLabel).toHaveAttribute("aria-checked", "false");
await page.keyboard.press("Escape");
});
await test.step("editing a label is one operation and preserves its assignments", async () => {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-display-label-filter").click();
await page.getByTestId("sidebar-label-manage").click();
await expect(page.getByText("Manage labels", { exact: true })).toBeVisible();
await page.getByTestId("workspace-label-manager-search").fill("urg");
await expect(page.getByTestId("workspace-label-manager-label-Frontend")).toBeHidden();
await page.getByTestId("workspace-label-manager-search").fill("");
await expect(page.getByTestId("workspace-label-manager-label-Frontend")).toBeVisible();
// A row is a label, not a button that swaps the surface — the pencil opens the edit, and
// it is only reachable once the row is hovered, the way it is only visible then.
await page.getByTestId("workspace-label-manager-label-Urgent").hover();
await page.getByTestId("workspace-label-manager-edit-Urgent").click();
// Phase 1's swatch names a colour by itself; the old "{{color}} label color" string is
// no longer rendered anywhere.
await expect(page.getByRole("radio", { name: "Red" })).toBeChecked();
await expect(page.getByRole("radio", { name: "Sky" })).not.toBeChecked();
await page.getByTestId("workspace-label-manager-name").fill("Priority");
await page.getByRole("radio", { name: "Amber" }).click();
// Picking a colour picks a colour: nothing has left for the host yet.
await expect(page.getByTestId("workspace-label-manager-save")).toBeVisible();
mutationFailure.failNextUpdate();
await page.getByTestId("workspace-label-manager-save").click();
await expect(page.getByTestId("workspace-label-manager-error")).toContainText(
"Injected label mutation failure.",
);
// Nothing half-applied: the view is still open on the draft exactly as typed.
await expect(page.getByTestId("workspace-label-manager-name")).toHaveValue("Priority");
await expect(page.getByRole("radio", { name: "Amber" })).toBeChecked();
await page.getByTestId("workspace-label-manager-save").click();
await expect(page.getByTestId("workspace-label-manager-label-Priority")).toBeVisible();
await expect(page.getByTestId("workspace-label-manager-label-Urgent")).toBeHidden();
await expect.poll(readWorkspaceLabels.bind(null, seeded)).toEqual(["Frontend", "Priority"]);
});
await test.step("a rejected creation keeps the draft and retries in place", async () => {
await page.keyboard.press("Escape");
await openWorkspaceLabels(page, seeded.workspaceId);
await page.getByTestId("workspace-label-picker-create").click();
await page.getByTestId("workspace-label-picker-create-name").fill("Retry");
mutationFailure.failNextAssignment();
await page.getByTestId("workspace-label-picker-create-submit").click();
await expect(page.getByTestId("workspace-label-picker-error")).toContainText(
"Injected label mutation failure.",
);
await expect(page.getByTestId("workspace-label-picker-create-name")).toHaveValue("Retry");
await page.getByTestId("workspace-label-picker-create-submit").click();
await expectAssigned(page, "Retry", true);
await page.keyboard.press("Escape");
});
} finally {
await unlabelled.cleanup();
await seeded.cleanup();
}
});
test.describe("compact picker", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("toggles in place and pushes the create page over the list", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-touch-" });
try {
await seeded.client.setWorkspaceLabel({
workspaceId: seeded.workspaceId,
label: { name: "Touch", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
const serverId = getServerId();
await page.getByTestId(`sidebar-workspace-kebab-${serverId}:${seeded.workspaceId}`).click();
await page
.getByTestId(`sidebar-workspace-menu-labels-${serverId}:${seeded.workspaceId}`)
.tap();
await expectAssigned(page, "Touch", true);
await labelRow(page, "Touch").tap();
await expectAssigned(page, "Touch", false);
await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible();
// The pushed page replaces the list rather than unfolding under it.
await page.getByTestId("workspace-label-picker-create").tap();
await expect(page.getByTestId("workspace-label-picker-create-name")).toBeVisible();
await expect(labelRow(page, "Touch")).toBeHidden();
await page.getByTestId("workspace-label-picker-create-name").fill("Handheld");
await page.getByTestId("workspace-label-swatch-emerald").tap();
await page.getByTestId("workspace-label-picker-create-submit").tap();
await expectAssigned(page, "Handheld", true);
await expectAssigned(page, "Touch", false);
} finally {
await seeded.cleanup();
}
});
});
});
@@ -11,7 +11,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
@@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -6,18 +6,17 @@ import {
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
import { getServerId } from "../support/helpers/server-id";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
// Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is
// a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces.
@@ -25,8 +24,14 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
// On desktop the file explorer is pinned open; on narrower layouts it must be
// toggled first. Open it either way, then select the Files tab.
async function openFilesTab(page: Page): Promise<void> {
await openFilesPanel(page);
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
if (await openButton.isVisible().catch(() => false)) {
await openButton.click();
}
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
}
@@ -34,17 +39,16 @@ async function createWorkspaceViaUi(
page: Page,
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to
// touch, the isolation is implicitly local.
isolation: "local" | "worktree" | null;
// null when the project has no git checkout: Local is the only available runtime.
runtime: "local" | "worktree" | null;
previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
if (input.runtime !== null) {
await selectWorkspaceRuntime(page, input.runtime);
}
await submitNewWorkspaceEmpty(page);
@@ -92,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
isolation: "local",
runtime: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -123,9 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
}
});
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-worktree-",
});
@@ -144,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, {
project,
isolation: "worktree",
runtime: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -195,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
// Non-git project: Local is the only available runtime.
runtime: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -79,12 +79,6 @@ async function getVisibleDraftTabCount(page: Page): Promise<number> {
}
async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
const tab = page
.locator('[data-testid^="workspace-tab-draft"]')
.filter({ visible: true })
.first();
await expect(tab).toBeVisible({ timeout: 30_000 });
await tab.hover();
const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
visible: true,
});
@@ -9,7 +9,6 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { clickSettingsBackToWorkspace, openCompactSettings } from "../support/helpers/settings";
import { openSettings } from "../support/helpers/app";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
clickFirstTerminalTab,
waitForWorkspaceTabsVisible,
@@ -66,7 +65,7 @@ test.describe("Workspace pane mounting", () => {
.elementHandle();
expect(originalComposer).not.toBeNull();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
2,
{ timeout: 30_000 },
@@ -0,0 +1,47 @@
import { test, expect } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { togglePinFromMenu, tabRowPin } from "../support/helpers/pins";
import { expectTerminalTabOpen } from "../support/helpers/workspace-tabs";
import type { PinnedTabTarget } from "../../src/workspace-pins/target";
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "workspace-pins-e2e-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test.describe("Pinned tab targets", () => {
test("pinning a target from the dropdown adds its quick-launch button to the tab row, unpinning removes it", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0);
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toBeVisible({ timeout: 10_000 });
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0, { timeout: 10_000 });
});
test("clicking the pinned quick-launch button in the tab row opens a terminal tab", async ({
page,
}) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, TERMINAL_TARGET)).toBeVisible({ timeout: 10_000 });
await tabRowPin(page, TERMINAL_TARGET).click();
await expectTerminalTabOpen(page);
});
});
@@ -59,15 +59,16 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
const agents = [];
for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
agents.push(
await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
);
}
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
@@ -369,10 +370,7 @@ test.describe("Worktree restore", () => {
timeout: 30_000,
});
await expect(
page.getByText(
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
page.getByText("The archived workspace runtime is missing.", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally {
@@ -7,8 +7,6 @@ interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
}
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
@@ -30,8 +28,6 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
type: "send_agent_message_request",
requestId: request.requestId,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
};
} catch {
return null;
@@ -1,244 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Dialog, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import {
addDirectHostFromSettings,
goBackInSettings,
openCompactSettings,
openHostSection,
selectSettingsHost,
} from "./settings";
type AgentSkillsClient = Pick<
InternalDaemonClient,
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
>;
export type SkillTargetName = "agents" | "claude" | "codex";
export interface AgentSkillsSandbox {
available: string[];
client: AgentSkillsClient;
daemon: IsolatedHostDaemon;
home: string;
targets: Record<SkillTargetName, string>;
blockAgentsDirectory(): Promise<void>;
unblockAgentsDirectory(): Promise<void>;
close(): Promise<void>;
getStatus(): ReturnType<AgentSkillsClient["getAgentSkillsStatus"]>;
install(selection: AgentSkillSelection): Promise<void>;
keepSkillOnlyIn(skill: string, target: SkillTargetName): Promise<void>;
placeSkillOnDisk(
skill: string,
target: SkillTargetName,
files?: Record<string, string>,
): Promise<void>;
}
export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-e2e-"));
const home = path.join(root, "home");
await mkdir(home, { recursive: true });
const daemon = await startIsolatedHostDaemon(`agent-skills-${randomUUID()}`, {
environment: {
HOME: home,
NODE_ENV: "development",
CLAUDE_CONFIG_DIR: path.join(root, "ignored-claude-config"),
CODEX_HOME: path.join(root, "ignored-codex-home"),
},
});
const client = await connectDaemonClient<AgentSkillsClient>({
clientIdPrefix: "app-e2e-agent-skills",
port: daemon.port,
});
const available = (await client.getAgentSkillsStatus()).available;
const targets = {
agents: path.join(home, ".agents", "skills"),
claude: path.join(home, ".claude", "skills"),
codex: path.join(home, ".codex", "skills"),
};
return {
available,
client,
daemon,
home,
targets,
blockAgentsDirectory: async () => {
await mkdir(path.dirname(targets.agents), { recursive: true });
await writeFile(targets.agents, "not a directory", "utf8");
},
unblockAgentsDirectory: async () => {
await rm(targets.agents, { force: true });
await mkdir(targets.agents, { recursive: true });
},
install: async (selection) => {
const result = await client.saveAgentSkillsSelection(selection, available);
if (result.confirmationRequired) {
throw new Error(`Unexpected removal confirmation: ${result.confirmationRequired.removals}`);
}
},
keepSkillOnlyIn: async (skill, target) => {
for (const [name, directory] of Object.entries(targets)) {
if (name === target) continue;
await rm(path.join(directory, skill), { recursive: true, force: true });
}
},
placeSkillOnDisk: async (skill, target, files = { "SKILL.md": `# ${skill}\n` }) => {
const directory = path.join(targets[target], skill);
for (const [relativePath, contents] of Object.entries(files)) {
const file = path.join(directory, relativePath);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
},
close: async () => {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
},
getStatus: async () => {
try {
return await client.getAgentSkillsStatus();
} catch {
await client.connect();
return client.getAgentSkillsStatus();
}
},
};
}
export async function openAgentSkillsSettings(
page: Page,
sandbox: AgentSkillsSandbox,
options: { compact?: boolean } = {},
): Promise<void> {
await gotoAppShell(page);
if (options.compact) {
await openCompactSettings(page, buildOpenProjectRoute());
} else {
await openSettings(page);
}
await addDirectHostFromSettings(page, {
host: "127.0.0.1",
port: sandbox.daemon.port,
});
if (options.compact) await goBackInSettings(page);
await selectSettingsHost(page, sandbox.daemon.serverId);
await openHostSection(page, sandbox.daemon.serverId, "agents");
await expect(page.getByTestId("host-agent-skills-card")).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function chooseCustomSkills(page: Page, selected: string[]): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).toBeChecked();
await all.click();
const keep = new Set(selected);
for (const name of await listSkillChoices(page)) {
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, selected);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).not.toBeChecked();
await all.click();
await expect(all).toBeChecked();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export function answerNextRemovalWarning(
page: Page,
answer: "accept" | "dismiss",
): Promise<Dialog> {
return new Promise((resolve) => {
page.once("dialog", (dialog) => {
resolve(dialog);
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
});
});
}
export async function expectInstalledSkills(
sandbox: AgentSkillsSandbox,
expected: string[],
): Promise<void> {
for (const directory of Object.values(sandbox.targets)) {
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
}
}
export async function expectSavedSelection(
sandbox: AgentSkillsSandbox,
expected: AgentSkillSelection,
): Promise<void> {
await expect.poll(async () => (await sandbox.getStatus()).selection).toEqual(expected);
}
export async function expectSkillFile(
sandbox: AgentSkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
await expect
.poll(() => readFile(path.join(sandbox.targets[target], skill, relativePath), "utf8"))
.not.toBe("");
}
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
const expected = new Set(selected);
for (const name of await listSkillChoices(page)) {
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
checked: expected.has(name),
});
}
}
async function listSkillChoices(page: Page): Promise<string[]> {
return page
.getByRole("checkbox")
.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("aria-label") ?? "")
.filter((name) => name.length > 0),
);
}
async function readInstalledSkills(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
@@ -58,7 +58,7 @@ function inFlightTurn(page: Page) {
async function openForkMenu(page: Page, trigger: ReturnType<Page["getByRole"]>): Promise<void> {
await trigger.click();
await expect(page.getByRole("menuitem", { name: "Fork in a new tab" })).toBeVisible({
await expect(page.getByRole("button", { name: "Fork in a new tab" })).toBeVisible({
timeout: 10_000,
});
}
@@ -80,7 +80,7 @@ export async function openMostRecentAssistantForkMenu(page: Page): Promise<void>
export async function forkMostRecentAssistantTurnToNewTab(page: Page): Promise<void> {
await openMostRecentAssistantForkMenu(page);
await page.getByRole("menuitem", { name: "Fork in a new tab" }).click();
await page.getByRole("button", { name: "Fork in a new tab" }).click();
}
export async function expectInFlightForkAvailable(page: Page): Promise<void> {
@@ -92,12 +92,12 @@ export async function expectInFlightForkAvailable(page: Page): Promise<void> {
export async function forkInFlightTurnToNewTab(page: Page): Promise<void> {
const trigger = inFlightTurn(page).getByRole("button", { name: "Fork chat" });
await openForkMenu(page, trigger);
await page.getByRole("menuitem", { name: "Fork in a new tab" }).click();
await page.getByRole("button", { name: "Fork in a new tab" }).click();
}
export async function forkMostRecentAssistantTurnToNewWorkspace(page: Page): Promise<void> {
await openMostRecentAssistantForkMenu(page);
await page.getByRole("menuitem", { name: "Fork in a new workspace" }).click();
await page.getByRole("button", { name: "Fork in a new workspace" }).click();
}
export async function expectChatHistoryAttachment(page: Page): Promise<void> {
@@ -1,8 +1,8 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// The branch switcher lives in the git diff panel's Changes header (right-side
// ExplorerSidebar), not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
@@ -16,9 +16,17 @@ function branchSwitcherTrigger(page: Page, branchName: string) {
.first();
}
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
// Opens the right-side explorer and lands on the Changes tab, where the branch
// switcher and diff live. Git checkouts default to the Changes tab when the
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
export async function openChangesPanel(page: Page): Promise<void> {
await openWorkspaceChangesPanel(page);
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
@@ -1,7 +1,6 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
export function chatOutlineRail(page: Page): Locator {
return page.getByTestId("chat-outline-rail");
@@ -42,7 +41,7 @@ export async function clickChatOutlineRowEdge(page: Page, position: number): Pro
}
export async function splitCurrentPanelRight(page: Page): Promise<void> {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByRole("button", { name: "Split pane right" }).first().click();
}
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
@@ -64,12 +64,3 @@ export async function createTerminalFromCommandCenter(page: Page, panel: Locator
await page.keyboard.press("Enter");
await expectTerminalTabOpen(page);
}
export async function runWorkspaceActionFromCommandCenter(
page: Page,
title: string,
): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await action(panel, title).click();
}
+2 -12
View File
@@ -82,7 +82,7 @@ export async function openAttachmentMenu(page: Page): Promise<void> {
export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise<void> {
const title = page.getByText("Add attachment", { exact: true });
const firstItemGlyph = page
.getByRole("menuitem", { name: "Add image", exact: true })
.getByRole("button", { name: "Add image", exact: true })
.locator("svg")
.first();
await waitForSettledPosition(title);
@@ -198,20 +198,13 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
workspaceId: string;
agentId: string;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
export async function startRunningMockAgent(
page: Page,
opts: {
prefix: string;
model: string;
prompt: string;
featureValues?: Record<string, unknown>;
},
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = getServerId();
@@ -229,7 +222,6 @@ export async function startRunningMockAgent(
cwd: repo.path,
workspaceId: workspace.id,
model: opts.model,
featureValues: opts.featureValues,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
@@ -241,8 +233,6 @@ export async function startRunningMockAgent(
return {
client,
repo,
workspaceId: workspace.id,
agentId: agent.id,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
@@ -20,9 +20,6 @@ interface ClientRequest {
payload?: unknown;
mode?: unknown;
path?: unknown;
agentId?: unknown;
text?: unknown;
activeTurnBehavior?: unknown;
}
interface ServerMessage {
@@ -51,11 +48,6 @@ interface HeldServerMessage {
blockedAgentId?: string;
}
interface PendingServerMessageHold {
matches: (message: ClientRequest | null) => boolean;
blockAgentStreamFollowers?: boolean;
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
@@ -133,42 +125,6 @@ function forceTimelineReset(message: string | Buffer, enabled: boolean): string
return JSON.stringify(envelope);
}
// The daemon refusal this reproduces: a Codex thread that already has an active writer.
const TIMELINE_WRITER_CONFLICT_ERROR =
"Failed to resume Codex thread playwright-thread: thread playwright-thread already has an active writer";
function failTimelineResponse(message: string | Buffer, agentId: string | null): string | Buffer {
if (!agentId || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload || payload.agentId !== agentId) return message;
payload.error = TIMELINE_WRITER_CONFLICT_ERROR;
payload.entries = [];
return JSON.stringify(envelope);
}
function rewriteShellToolCommand(
message: string | Buffer,
command: string | null,
): string | Buffer {
if (!command || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
};
const event = (envelope.message?.payload ?? envelope.payload)?.event;
if (event?.type !== "timeline" || event.item?.type !== "tool_call") return message;
const detail = event.item.detail;
if (!detail || typeof detail !== "object" || (detail as { type?: unknown }).type !== "shell") {
return message;
}
(detail as { command?: unknown }).command = command;
return JSON.stringify(envelope);
}
function readAgentStreamEventType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
@@ -196,36 +152,6 @@ function readAgentStreamItemType(message: ClientRequest | null): string | null {
: null;
}
function matchesToolCall(
message: ClientRequest | null,
input: { status: string; command?: string },
): boolean {
if (readAgentStreamItemType(message) !== "tool_call") return false;
const event = (
message?.payload as
| {
event?: { item?: { status?: unknown; detail?: { command?: unknown } } };
}
| undefined
)?.event;
return (
event?.item?.status === input.status &&
(input.command === undefined || event.item.detail?.command === input.command)
);
}
function matchesShellToolCall(message: ClientRequest | null, status: string): boolean {
if (readAgentStreamItemType(message) !== "tool_call") return false;
const event = (
message?.payload as
| {
event?: { item?: { status?: unknown; detail?: { type?: unknown } } };
}
| undefined
)?.event;
return event?.item?.status === status && event.item.detail?.type === "shell";
}
function shouldSuppressServerMessage(input: {
message: ClientRequest | null;
messageTypes: ReadonlySet<string>;
@@ -300,15 +226,10 @@ export async function installDaemonWebSocketGate(page: Page) {
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
let stripCanonicalSubmittedPromptsFeature = false;
let shellToolCommandOverride: string | null = null;
let failingTimelineAgentId: string | null = null;
let holdingTimelineAgentId: string | null = null;
const heldTimelineResponses: Array<() => void> = [];
const heldTimelineResponseWaiters = new Set<() => void>();
let heldClientRequestType: string | null = null;
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldClientRequest: (() => void) | null = null;
const pendingServerMessageHolds = new Map<string, PendingServerMessageHold>();
const pendingServerMessageHolds = new Map<string, (message: ClientRequest | null) => boolean>();
const heldServerMessages: HeldServerMessage[] = [];
const heldServerMessageWaiters = new Set<() => void>();
const suppressedServerMessageTypes = new Set<string>();
@@ -322,7 +243,6 @@ export async function installDaemonWebSocketGate(page: Page) {
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const clientRequests = new Map<string, ClientRequest[]>();
const timelineRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const agentStreamEventCounts = new Map<string, number>();
@@ -402,11 +322,11 @@ export async function installDaemonWebSocketGate(page: Page) {
if (!suppressed) blocked.agentStreamFollowers.push(input.message);
return true;
}
const matchedHold = Array.from(pendingServerMessageHolds).find(([, hold]) =>
hold.matches(input.parsed),
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
matches(input.parsed),
);
if (!matchedHold) return suppressed;
const [key, hold] = matchedHold;
const [key] = matchedHold;
pendingServerMessageHolds.delete(key);
heldServerMessages.push({
browser: input.browser,
@@ -414,7 +334,7 @@ export async function installDaemonWebSocketGate(page: Page) {
key,
agentStreamFollowers: [],
blockedAgentId:
hold.blockAgentStreamFollowers || readAgentStreamEventType(input.parsed) === "turn_started"
readAgentStreamEventType(input.parsed) === "turn_started"
? (agentId ?? undefined)
: undefined,
});
@@ -465,11 +385,6 @@ export async function installDaemonWebSocketGate(page: Page) {
}
const request = readClientRequest(message);
recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts);
if (typeof request?.type === "string") {
const requests = clientRequests.get(request.type) ?? [];
requests.push(request);
clientRequests.set(request.type, requests);
}
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
@@ -502,31 +417,16 @@ export async function installDaemonWebSocketGate(page: Page) {
stripAssistantMessageIds,
serverMessage?.type,
);
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
outboundMessage = stripCanonicalSubmittedPrompts(
outboundMessage,
stripCanonicalSubmittedPromptsFeature,
serverMessage?.type,
);
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
if (isTimelineResponse) {
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
}
const shouldForceTimelineReset =
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
recordServerMessage(serverMessage);
if (isTimelineResponse && holdingTimelineAgentId) {
const payload = (serverMessage as { payload?: { agentId?: unknown } } | null)?.payload;
if (payload?.agentId === holdingTimelineAgentId) {
const forward = outboundMessage;
heldTimelineResponses.push(() => ws.send(forward));
for (const resolve of heldTimelineResponseWaiters) resolve();
heldTimelineResponseWaiters.clear();
return;
}
}
if (holdServerMessage({ browser: ws, message: outboundMessage, parsed: serverMessage }))
return;
if (holdReadyFileUpdate(ws, outboundMessage, fileMessage)) return;
@@ -595,24 +495,6 @@ export async function installDaemonWebSocketGate(page: Page) {
restore(): void {
acceptingConnections = true;
},
failTimelineResponses(agentId: string): void {
failingTimelineAgentId = agentId;
},
allowTimelineResponses(): void {
failingTimelineAgentId = null;
},
holdTimelineResponses(agentId: string): void {
holdingTimelineAgentId = agentId;
},
async waitForHeldTimelineResponse(): Promise<void> {
while (heldTimelineResponses.length === 0) {
await new Promise<void>((resolve) => heldTimelineResponseWaiters.add(resolve));
}
},
releaseHeldTimelineResponses(): void {
holdingTimelineAgentId = null;
for (const forward of heldTimelineResponses.splice(0)) forward();
},
restoreFresh(): void {
reconnectWithFreshClient = true;
acceptingConnections = true;
@@ -634,36 +516,25 @@ export async function installDaemonWebSocketGate(page: Page) {
heldClientRequestType = null;
},
holdNextServerMessage(type: string): void {
pendingServerMessageHolds.set(serverMessageKey(type), {
matches: (message) => message?.type === type,
});
pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type);
},
holdNextAgentUpdate(agentId: string, status: string): void {
const heldAgentUpdate = { agentId, status };
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), {
matches: (message) => matchesAgentUpdate(message, heldAgentUpdate),
});
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) =>
matchesAgentUpdate(message, heldAgentUpdate),
);
},
holdNextAgentStreamEvent(type: string): void {
pendingServerMessageHolds.set(agentStreamEventKey(type), {
matches: (message) => readAgentStreamEventType(message) === type,
});
pendingServerMessageHolds.set(
agentStreamEventKey(type),
(message) => readAgentStreamEventType(message) === type,
);
},
holdNextAgentStreamItem(type: string): void {
pendingServerMessageHolds.set(agentStreamItemKey(type), {
matches: (message) => readAgentStreamItemType(message) === type,
});
},
holdNextToolCall(input: { status: string; command?: string }): void {
pendingServerMessageHolds.set(`tool-call:${input.status}:${input.command ?? ""}`, {
matches: (message) => matchesToolCall(message, input),
});
},
holdNextShellToolCall(status: string): void {
pendingServerMessageHolds.set(`shell-tool-call:${status}`, {
matches: (message) => matchesShellToolCall(message, status),
blockAgentStreamFollowers: true,
});
pendingServerMessageHolds.set(
agentStreamItemKey(type),
(message) => readAgentStreamItemType(message) === type,
);
},
async waitForHeldServerMessage(type?: string): Promise<void> {
const key = type ? serverMessageKey(type) : null;
@@ -815,9 +686,6 @@ export async function installDaemonWebSocketGate(page: Page) {
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
stripCanonicalSubmittedPromptsFeature = stripped;
},
setShellToolCommandOverride(command: string | null): void {
shellToolCommandOverride = command;
},
setAgentStreamSuppressed(suppressed: boolean): void {
suppressAgentStream = suppressed;
},
@@ -834,9 +702,6 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
return [...(clientRequests.get(type) ?? [])];
},
getTimelineRequestCount(direction: "tail" | "before" | "after"): number {
return timelineRequestCounts.get(direction) ?? 0;
},
+2 -2
View File
@@ -30,7 +30,7 @@ export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Pr
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await expect(svg).toBeVisible({ timeout: 100 });
await page.waitForTimeout(25);
}
}
@@ -44,5 +44,5 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
}
export async function reloadConversation(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
await page.reload();
}
+71 -1
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
@@ -139,6 +140,74 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
);
}
async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void> {
if (process.env.E2E_DESKTOP_RUNTIME !== "1") return;
const configPath = path.join(paseoHome, "config.json");
const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {};
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const materializeRoot = path.join(paseoHome, "fixture-workspaces");
const fixtureProviderSource = path.resolve(
__dirname,
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
);
await mkdir(stateDirectory, { recursive: true });
await writeFile(
configPath,
`${JSON.stringify({
...existing,
version: 1,
agents: {
...existing.agents,
providers: {
...existing.agents?.providers,
claude: { ...existing.agents?.providers?.claude, enabled: false },
codex: { ...existing.agents?.providers?.codex, enabled: false },
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
opencode: { ...existing.agents?.providers?.opencode, enabled: false },
pi: { ...existing.agents?.providers?.pi, enabled: false },
"fixture-agent": {
extends: "acp",
label: "Fixture Agent",
command: [process.execPath, "./.paseo-fixture-agent.mjs"],
models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }],
enabled: true,
},
},
},
workspaceRuntimes: {
...existing.workspaceRuntimes,
fixture: {
type: "command",
label: "Fixture",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory,
materializeRoot,
fixtureProviderSource,
preserveSourceDisplayCwd: true,
},
},
"fixture-failure": {
type: "command",
label: "Fixture Failure",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
failCreate: true,
createError: "Fixture probe creation failed",
},
},
},
})}\n`,
);
}
export async function startE2EWorker(
workerIndex: number,
options: { forkProviders?: string[] } = {},
@@ -154,6 +223,7 @@ export async function startE2EWorker(
try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
await configureDesktopFixtureRuntime(paseoHome);
const daemon = await startIsolatedHostDaemon(serverId, {
paseoHome,
preserveHome,
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
return page.getByTestId("file-explorer-tree-scroll");
@@ -10,7 +9,8 @@ function fileExplorerEntry(page: Page, name: string) {
}
export async function openFileExplorer(page: Page): Promise<void> {
await openFilesPanel(page);
await page.getByRole("button", { name: "Open explorer" }).first().click();
await page.getByTestId("explorer-tab-files").click();
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
}
+2 -2
View File
@@ -177,7 +177,7 @@ export async function renameHostFromSettings(page: Page, name: string): Promise<
export async function chooseHostColor(page: Page, colorName: string): Promise<void> {
await page.getByRole("button", { name: /^Color, / }).click();
await page.getByRole("menuitem", { name: colorName, exact: true }).click();
await page.getByRole("button", { name: colorName, exact: true }).click();
await expect(page.getByRole("button", { name: `Color, ${colorName}` })).toBeVisible();
}
@@ -186,7 +186,7 @@ export async function chooseHostBadgeDisplay(
option: "Name" | "Icon only" | "Hidden",
): Promise<void> {
await page.getByTestId("host-appearance-badge-display").click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await page.getByRole("button", { name: option, exact: true }).click();
await expect(page.getByRole("button", { name: `Sidebar badge, ${option}` })).toBeVisible();
}
+25 -69
View File
@@ -2,7 +2,6 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { getServerId } from "./server-id";
import { createAgentTabFromMenu } from "./workspace-tabs";
// ─── Navigation ────────────────────────────────────────────────────────────
@@ -62,28 +61,18 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
}
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+${key}`);
}
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** Assert the inline plus button is visible in the tab bar. */
/** Assert the inline new-agent plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
).toBeVisible();
}
@@ -96,9 +85,14 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
/** Click the inline plus button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
const button = page
.getByTestId("workspace-new-agent-tab-inline")
.filter({ visible: true })
.first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Open the new-tab menu and click "New terminal". */
@@ -135,9 +129,9 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the inline plus button is visible in the tab bar. */
/** Assert the inline new-agent plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -168,59 +162,21 @@ export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
): Promise<Array<Array<{ id: string; width: number }>>> {
await page.evaluate((duration) => {
const scope = globalThis as typeof globalThis & {
__paseoTabTrackFrames?: Array<Array<{ id: string; width: number }>>;
};
scope.__paseoTabTrackFrames = [];
const startedAt = performance.now();
function sample() {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
),
).filter((element) => element.getClientRects().length > 0);
scope.__paseoTabTrackFrames?.push(
tabs.map((element) => ({
id: element.getAttribute("data-testid") ?? "",
width: Math.round(element.getBoundingClientRect().width),
})),
);
if (performance.now() - startedAt < duration) {
requestAnimationFrame(sample);
}
intervalMs = 30,
): Promise<string[][]> {
const snapshots: string[][] = [];
const startSampling = async () => {
const start = Date.now();
while (Date.now() - start < durationMs) {
snapshots.push(await getTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
requestAnimationFrame(sample);
}, durationMs);
await action();
await page.waitForTimeout(durationMs + 100);
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & {
__paseoTabTrackFrames?: Array<Array<{ id: string; width: number }>>;
};
return scope.__paseoTabTrackFrames ?? [];
});
}
};
export async function expectTabTitleFits(
page: Page,
title: string,
widthRange: { min: number; max: number },
): Promise<void> {
const tab = page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: title })
.filter({ visible: true })
.last();
await expect(tab).toContainText(title);
const tabWidth = await tab.evaluate((element) => element.getBoundingClientRect().width);
expect(tabWidth).toBeGreaterThanOrEqual(widthRange.min);
expect(tabWidth).toBeLessThanOrEqual(widthRange.max);
const label = tab.getByText(title, { exact: true });
await expect(label).toBeVisible();
const labelFits = await label.evaluate((element) => element.scrollWidth <= element.clientWidth);
expect(labelFits).toBe(true);
const samplingPromise = startSampling();
await action();
await samplingPromise;
return snapshots;
}
export function terminalSurfaceLocator(page: Page) {
@@ -84,9 +84,9 @@ export async function openAgentRoute(
page: Page,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.workspaceId, input.agentId), { waitUntil: "commit" });
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000, waitUntil: "commit" },
{ timeout: 60_000 },
);
}
@@ -0,0 +1,330 @@
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { waitForConnectedHost } from "./hosts";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "./new-workspace";
import { connectSeedClient } from "./seed-client";
import { getServerId } from "./server-id";
import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeededRuntimeProject {
projectId: string;
projectKey: string;
projectDisplayName: string;
sourceDirectory: string;
cleanup(): Promise<void>;
}
const SETUP_MARKER = "runtime-user-setup-ran.txt";
export async function seedGitProjectForRuntime(
options?: Parameters<typeof createTempGitRepo>[1],
): Promise<SeededRuntimeProject> {
const repo = await createTempGitRepo("runtime-selector-", {
...options,
paseoConfig: options?.paseoConfig ?? {
worktree: { setup: [`printf setup > ${SETUP_MARKER}`] },
},
});
return seedRuntimeProject(repo);
}
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "provider-probes.json"), "utf8"),
) as Array<{ workspaceId: string; projectId: string }>;
return records.filter((record) => record.projectId === projectId);
}
async function markerExists(root: string): Promise<boolean> {
return access(path.join(root, SETUP_MARKER)).then(
() => true,
() => false,
);
}
export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise<void> {
expect(await markerExists(project.sourceDirectory)).toBe(false);
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const stateFiles = await readdir(stateDirectory);
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const probe = states.find((state) => probeIds.has(state.workspaceId));
expect(probe, "fixture probe runtime state").toBeDefined();
expect(await markerExists(probe!.root)).toBe(false);
}
export async function expectUserWorkspaceRanProjectSetup(
project: SeededRuntimeProject,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture",
);
expect(workspace, "fixture user workspace record").toBeDefined();
const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime"));
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId);
expect(state, "fixture user runtime state").toBeDefined();
await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true);
}
export async function expectProviderAvailable(page: Page, providerLabel: string): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function selectRuntimeProviderModel(
page: Page,
input: { provider: string; model: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
await dialog.getByText(input.provider, { exact: true }).click();
const model = dialog.getByText(input.model, { exact: true });
await expect(model).toBeVisible({ timeout: 30_000 });
await model.click();
await expect(trigger).toContainText(input.model);
}
export async function expectRuntimeProviderUnavailable(
page: Page,
providerLabel: string,
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
const providerId = providerLabel.toLowerCase().replaceAll(" ", "");
const providerRow = dialog.getByTestId(`model-provider-${providerId}`);
await expect(providerRow).toBeVisible({ timeout: 30_000 });
await expect(providerRow).toHaveText(`${providerLabel}Error`);
await page.keyboard.press("Escape");
}
export async function expectFixtureProviderUnavailable(page: Page): Promise<void> {
const providerLabel = "Fixture Agent";
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible();
await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible();
await page.keyboard.press("Escape");
}
export async function expectProbeFailureWithRetry(page: Page, message: string): Promise<void> {
await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 });
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled();
await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible();
}
export async function retryFailedProbe(page: Page): Promise<void> {
await page.getByRole("button", { name: "Retry", exact: true }).click();
}
export async function expectNoProbeInWorkspaceProjection(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false);
for (const probeId of probeIds) {
await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0);
}
} finally {
await client.close();
}
}
export async function seedNonGitProjectForRuntime(): Promise<SeededRuntimeProject> {
const directory = await createTempDirectory("runtime-selector-non-git-");
return seedRuntimeProject(directory);
}
async function seedRuntimeProject(resource: {
path: string;
cleanup(): Promise<void>;
}): Promise<SeededRuntimeProject> {
const client = await connectSeedClient();
const added = await client.addProject(resource.path);
if (added.error || !added.project) {
await client.close();
await resource.cleanup();
throw new Error(added.error ?? "Runtime project was not added");
}
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === added.project?.projectId,
);
if (!project?.projectKey) {
await client.close();
await resource.cleanup();
throw new Error("Runtime project has no project key");
}
return {
projectId: added.project.projectId,
projectKey: project.projectKey,
projectDisplayName: added.project.projectDisplayName,
sourceDirectory: resource.path,
cleanup: async () => {
await client.removeProject(added.project!.projectId);
await client.close();
await resource.cleanup();
},
};
}
export async function gotoNewWorkspaceForRuntime(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, project);
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText("Local");
}
export async function expectRuntimeChoices(page: Page, labels: readonly string[]): Promise<void> {
await page.getByRole("button", { name: "Runtime", exact: true }).click();
const dialog = page.getByRole("dialog").last();
for (const label of labels) {
await expect(dialog.getByRole("button", { name: label, exact: true })).toBeVisible();
}
await expect(dialog.getByRole("button")).toHaveCount(labels.length);
await page.keyboard.press("Escape");
}
export async function expectRuntimeSelected(page: Page, label: string): Promise<void> {
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText(label);
}
export async function selectRuntime(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: label, exact: true }).click();
await expect(trigger).toContainText(label);
}
export async function createWorkspaceInSelectedRuntime(page: Page): Promise<void> {
await submitNewWorkspaceEmpty(page);
}
export async function expectWorkspaceOpenInRuntime(
page: Page,
project: SeededRuntimeProject,
runtimeId: string,
): Promise<string> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const workspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
timeoutMs: 120_000,
});
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId);
expect(record?.runtime).toEqual({ runtimeId });
return workspace.workspaceId;
} finally {
await client.close();
}
}
export async function expectSelectedHostRuntimePlacement(
project: SeededRuntimeProject,
runtimeId: "local" | "worktree",
setupRan: boolean,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{
projectId: string;
cwd: string;
hostVisiblePath?: string | null;
runtime?: { runtimeId: string };
}>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId,
);
expect(workspace, `${runtimeId} selected workspace record`).toBeDefined();
expect(workspace?.hostVisiblePath).toBe(workspace?.cwd);
if (setupRan) {
await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true);
} else {
expect(await markerExists(workspace!.cwd)).toBe(false);
}
}
export async function expectHostWorkspaceAffordances(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible();
await page.getByRole("button", { name: "Choose editor" }).click();
await expect(page.getByText("VS Code", { exact: true })).toBeVisible();
await expect(page.getByText("Finder", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
@@ -19,7 +19,6 @@ type NewWorkspaceDaemonClient = Pick<
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "installDirectoryPlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
@@ -27,7 +26,6 @@ type NewWorkspaceDaemonClient = Pick<
| "removeProject"
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -223,7 +221,7 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -321,35 +319,61 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
// The isolation trigger renders the active isolation's label ("Local" / "New
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
type WorkspaceRuntimeChoice = "local" | "worktree";
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
local: "Local",
worktree: "New worktree",
worktree: "Worktree",
};
export async function expectWorkspaceIsolationSelected(
export async function expectWorkspaceRuntimeSelected(
page: Page,
isolation: "local" | "worktree",
runtime: WorkspaceRuntimeChoice,
): Promise<void> {
const trigger = page.getByRole("button", { name: "Workspace isolation" });
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible();
}
export async function selectWorkspaceIsolation(
export async function selectWorkspaceRuntime(
page: Page,
isolation: "local" | "worktree",
runtime: WorkspaceRuntimeChoice,
): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger");
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// Isolation options are derived from project capability. Wait for the option
// so this helper also covers route-to-project reconciliation.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
const runtimePicker = page.getByRole("dialog").last();
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
const option = runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
});
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expectWorkspaceRuntimeSelected(page, runtime);
}
export async function expectWorkspaceRuntimeChoices(
page: Page,
runtimes: readonly WorkspaceRuntimeChoice[],
): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const runtimePicker = page.getByRole("dialog").last();
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
for (const runtime of runtimes) {
await expect(
runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
}),
).toBeVisible();
}
await expect(runtimePicker.getByRole("button")).toHaveCount(runtimes.length);
await page.keyboard.press("Escape");
}
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
@@ -466,7 +490,7 @@ export async function pasteGithubPrUrl(
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
await composer.focus();
await page.keyboard.press("Control+V");
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
}
export async function assertNewWorkspaceSidebarAndHeader(
@@ -478,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
timeoutMs?: number;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
@@ -487,7 +512,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
{ timeout: input.timeoutMs ?? 60_000 },
)
.not.toBeNull();
+45
View File
@@ -0,0 +1,45 @@
import { expect, type Page } from "@playwright/test";
import type { PinnedTabTarget } from "../../../src/workspace-pins/target";
import { pinnedTargetKey } from "../../../src/workspace-pins/target";
// The new-tab dropdown menu items carry no stable web ARIA role for the inline
// pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is
// addressed through the test ids the feature assigns per target key — the same
// escape-hatch convention the sidebar kebab and tab context menus use.
function pinToggle(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pin-toggle-${pinnedTargetKey(target)}`);
}
function menuItemFor(page: Page, target: PinnedTabTarget) {
if (target.kind === "draft") {
return page.getByTestId("workspace-new-tab-menu-agent");
}
return page.getByTestId(`workspace-new-tab-menu-${target.kind}`);
}
export function tabRowPin(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pinned-target-${pinnedTargetKey(target)}`);
}
export async function openNewTabMenu(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
}
// The pin toggle is hidden behind reveal-on-hover (opacity + pointerEvents) on
// desktop web, so the menu item must be hovered before the toggle is clickable.
export async function togglePinFromMenu(page: Page, target: PinnedTabTarget): Promise<void> {
await openNewTabMenu(page);
const item = menuItemFor(page, target);
await expect(item).toBeVisible({ timeout: 10_000 });
await item.hover();
const toggle = pinToggle(page, target);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await toggle.click();
await page.keyboard.press("Escape");
}
+3 -2
View File
@@ -1,9 +1,10 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> {
await openPullRequestPanel(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-pr").click();
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
}
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
@@ -1,6 +1,12 @@
import { expect, type Page } from "@playwright/test";
export async function expectOpenedProject(page: Page, _projectName?: string): Promise<string> {
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectName })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();

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