mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd991b3e3 | ||
|
|
5fca5b7ccb | ||
|
|
9b7f12a6e9 | ||
|
|
1492bc659a | ||
|
|
345638dc7f | ||
|
|
929d2b717e | ||
|
|
ce905d9483 | ||
|
|
133c38c5c3 | ||
|
|
97a6deaa39 | ||
|
|
e12bdc2fc5 | ||
|
|
41298bd910 | ||
|
|
e883b692b0 | ||
|
|
5cd62ee2ec | ||
|
|
9b359b6452 | ||
|
|
ab619f982e | ||
|
|
e55dd5a212 | ||
|
|
91024bc029 | ||
|
|
ca2ee380df | ||
|
|
556bd0d36f | ||
|
|
63be2e4340 | ||
|
|
863090e0d7 | ||
|
|
1f1289641d | ||
|
|
c689f8bb94 | ||
|
|
b098a2640d | ||
|
|
b638f7d3ee | ||
|
|
e9373c026b | ||
|
|
9c9a85a0cf | ||
|
|
d10edbc5f8 | ||
|
|
60706feb34 | ||
|
|
936fd4b6da | ||
|
|
f1eeef32ac | ||
|
|
f9e1def954 | ||
|
|
f7e4606452 | ||
|
|
25fbc939e0 | ||
|
|
5731f016c2 | ||
|
|
403a32ad9f | ||
|
|
bd3f3c7714 | ||
|
|
c09be7efa2 | ||
|
|
c779ef06c5 | ||
|
|
5997cc1c17 | ||
|
|
6db261f911 | ||
|
|
0f2e58d4bb | ||
|
|
05ee7a1d31 | ||
|
|
ce9026f264 | ||
|
|
e2e97e4854 | ||
|
|
cd2b6bca9d | ||
|
|
4470cc4c56 | ||
|
|
ba91e3ba7c | ||
|
|
2347242cdf | ||
|
|
4108960709 | ||
|
|
eb3591cee8 | ||
|
|
ff6424fa4e | ||
|
|
a87c11e8ac | ||
|
|
b880a70e73 | ||
|
|
f9633dd359 | ||
|
|
78629f92ef | ||
|
|
17f06443dc | ||
|
|
3003ae25d5 | ||
|
|
63a8b28e63 | ||
|
|
6290c2f8ac | ||
|
|
405c6e07a9 | ||
|
|
2538dd1e7b | ||
|
|
ed8371d083 | ||
|
|
3cc8749437 | ||
|
|
5ed1a852d1 | ||
|
|
c859b59c99 | ||
|
|
1cc6712e51 | ||
|
|
a6a533bc6a | ||
|
|
f469223b76 | ||
|
|
75ccae13be | ||
|
|
7a61a642a9 | ||
|
|
3948bf4fd6 | ||
|
|
5ec26f0c09 | ||
|
|
df3e646fbe | ||
|
|
d7f892251f | ||
|
|
331bbd9faa | ||
|
|
311eb48922 | ||
|
|
f55f6d0599 | ||
|
|
851dcb8d4a | ||
|
|
53ba0f0a26 | ||
|
|
1e50f895f0 | ||
|
|
6ea1067bff | ||
|
|
60bafad3ee | ||
|
|
8655722296 | ||
|
|
5110e6e92a | ||
|
|
a54b6c2020 | ||
|
|
b97993f866 | ||
|
|
7d3212e365 | ||
|
|
ac6a0afdc7 | ||
|
|
58fe405801 | ||
|
|
b362af6e32 | ||
|
|
9e6e148ba2 | ||
|
|
8f7b3e8d75 | ||
|
|
c7b103a2cd | ||
|
|
4d23905587 | ||
|
|
d03cf14703 | ||
|
|
75adf53893 | ||
|
|
6d7606160e | ||
|
|
1e2089cef7 | ||
|
|
1917fdd131 | ||
|
|
f3202ac419 | ||
|
|
c8b2c8351f | ||
|
|
0eab0b7828 | ||
|
|
ac4331456c | ||
|
|
ac7796035d | ||
|
|
97ac967495 | ||
|
|
90c97c1b1a | ||
|
|
3136117906 | ||
|
|
ab274d635e | ||
|
|
6dcf6a4b08 | ||
|
|
d31372ef12 | ||
|
|
acc4738caf | ||
|
|
8c4e54eace | ||
|
|
a9e3be168c | ||
|
|
42245d139a | ||
|
|
16120ebff1 | ||
|
|
d99258cdd0 | ||
|
|
d4435bd016 | ||
|
|
d60d92e909 | ||
|
|
9a0c53d790 | ||
|
|
8625f0543a | ||
|
|
38f176595e | ||
|
|
7f8a485a77 | ||
|
|
4748aad103 | ||
|
|
75bbca4a1b | ||
|
|
23853a1515 | ||
|
|
f0bd2c8483 | ||
|
|
30195d3bd1 | ||
|
|
770b87a15c | ||
|
|
b03dc804eb | ||
|
|
373a98c64d | ||
|
|
599846650c | ||
|
|
b5610928a5 | ||
|
|
b44bb63cf4 | ||
|
|
4950d4c2ea | ||
|
|
4149eeb407 | ||
|
|
a732cc5756 | ||
|
|
6a1cc98fdf | ||
|
|
9c91dbbf42 | ||
|
|
2b9501a810 | ||
|
|
52e4060d76 | ||
|
|
a4b92768d9 | ||
|
|
0f27230746 | ||
|
|
9c195feb9b | ||
|
|
90f3d95415 | ||
|
|
1a6054138d | ||
|
|
20bccd31a3 | ||
|
|
d0d60a4052 | ||
|
|
9ca6f24315 | ||
|
|
36312352db | ||
|
|
c190fed27c | ||
|
|
9a7301d9a0 | ||
|
|
781e07f4ea | ||
|
|
44b9980a70 | ||
|
|
ed0d4e6c5b | ||
|
|
1b887e534b | ||
|
|
4426a6b11f | ||
|
|
fc1cb82e02 | ||
|
|
0c6dbefa40 | ||
|
|
94106a30ab | ||
|
|
635a8be437 | ||
|
|
8e916fd2dc | ||
|
|
474b4e70b4 | ||
|
|
124e512b58 | ||
|
|
0ba7251280 | ||
|
|
b58ccf9269 |
@@ -18,6 +18,7 @@ on:
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
|
||||
+41
-1
@@ -94,6 +94,11 @@
|
||||
"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"],
|
||||
@@ -120,6 +125,11 @@
|
||||
"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"],
|
||||
@@ -167,6 +177,11 @@
|
||||
"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"],
|
||||
@@ -192,11 +207,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",
|
||||
@@ -254,6 +269,11 @@
|
||||
"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"],
|
||||
@@ -274,6 +294,26 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
+106
@@ -1,5 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053))
|
||||
|
||||
### Added
|
||||
|
||||
- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208))
|
||||
- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
|
||||
- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
|
||||
- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
|
||||
- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
|
||||
- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
|
||||
- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243))
|
||||
- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
|
||||
|
||||
### Improved
|
||||
|
||||
- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240))
|
||||
- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
|
||||
- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214))
|
||||
- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323))
|
||||
- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
|
||||
- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107))
|
||||
- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
|
||||
- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
|
||||
- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
|
||||
- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224))
|
||||
- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
|
||||
- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311))
|
||||
- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
|
||||
- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
|
||||
- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
|
||||
- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
|
||||
- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
|
||||
- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
|
||||
- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289))
|
||||
- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279))
|
||||
- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331))
|
||||
- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315))
|
||||
- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai))
|
||||
|
||||
## 0.3.1 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
|
||||
@@ -23,6 +23,7 @@ RUN set -eux; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --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=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
|
||||
+29
-13
@@ -44,9 +44,21 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
Parent archive detaches a subagent instead of archiving it when either condition holds:
|
||||
|
||||
- The child belongs to another workspace.
|
||||
- The child is currently open in an agent tab.
|
||||
|
||||
All other children archive with the parent. After the workspace layout hydrates, the client marks
|
||||
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
|
||||
generic agent metadata update. This includes background and restored tabs; navigation does not own
|
||||
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
@@ -74,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
@@ -109,7 +121,7 @@ These are two distinct concepts that used to be conflated:
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
|
||||
|
||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
||||
|
||||
@@ -123,7 +135,9 @@ Running provider-native subagents contribute `running` to the workspace owned by
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
||||
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:
|
||||
|
||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
||||
|
||||
@@ -146,6 +160,7 @@ 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.
|
||||
@@ -153,9 +168,9 @@ 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 track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
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.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
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.
|
||||
|
||||
## Why this shape
|
||||
|
||||
@@ -173,7 +188,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 track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||
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.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
@@ -189,11 +204,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
+2
-2
@@ -82,11 +82,11 @@ Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
|
||||
+45
-19
@@ -64,18 +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/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/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/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 |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -91,25 +94,43 @@ 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.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
|
||||
- `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
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
The replica cache exists only to paint stale data immediately while the host connects. It does not
|
||||
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
|
||||
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
|
||||
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
|
||||
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
|
||||
written order; a single oversized host is omitted rather than partially restored.
|
||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
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
|
||||
|
||||
@@ -121,6 +142,7 @@ 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`
|
||||
@@ -158,6 +180,10 @@ 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
Provider definitions live under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
|
||||
agent catalog during startup, raise the limit in milliseconds:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"catalogRefreshTimeoutMs": 180000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The limit applies independently to every provider refresh and covers availability plus the entire
|
||||
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
+47
-4
@@ -57,6 +57,8 @@ $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/
|
||||
@@ -85,7 +87,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -175,6 +177,17 @@ 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
|
||||
result. Normal config patches persist only the requested fields, so launch overrides and resolved
|
||||
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
|
||||
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
|
||||
|
||||
```
|
||||
{
|
||||
version: 1,
|
||||
@@ -211,6 +224,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
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`.
|
||||
@@ -219,6 +235,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
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 } }
|
||||
@@ -278,9 +296,9 @@ Environment variables override `config.json`:
|
||||
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
|
||||
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
|
||||
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
|
||||
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
|
||||
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
@@ -429,10 +447,35 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `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.
|
||||
|
||||
### 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.
|
||||
|
||||
`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
|
||||
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
|
||||
|
||||
+8
-6
@@ -28,11 +28,13 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
||||
|
||||
## 3. Hierarchy
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **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`).
|
||||
- **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`).
|
||||
- **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`).
|
||||
|
||||
@@ -64,7 +66,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.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.
|
||||
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.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -225,13 +227,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 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 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 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.
|
||||
|
||||
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>`.
|
||||
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +244,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- 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. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§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.
|
||||
|
||||
+65
-6
@@ -217,6 +217,67 @@ 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
|
||||
@@ -266,8 +327,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
|
||||
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
|
||||
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
|
||||
semantics, and environment-variable overrides.
|
||||
|
||||
@@ -297,9 +358,7 @@ another remote fails closed until the worktree records an explicit local target.
|
||||
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
||||
exact ref also resolves through its stored branch name.
|
||||
|
||||
Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's
|
||||
`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other
|
||||
uncommitted source-checkout changes are not copied.
|
||||
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
@@ -464,7 +523,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 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 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.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ 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`, and `settings`.
|
||||
`agent/[agentId]`, `sessions`, `open-project`, `settings`,
|
||||
`plugin/[pluginId]/[surfaceId]`, and
|
||||
`plugin/[pluginId]/[contributionKind]/[contributionId]`.
|
||||
|
||||
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
|
||||
|
||||
@@ -16,4 +16,6 @@ Read aggregate health from the owning observer service. Runtime metrics include
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
@@ -61,6 +61,12 @@ 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**
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ 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 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`).
|
||||
- **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".
|
||||
- **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).
|
||||
|
||||
+5
-5
@@ -95,11 +95,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local
|
||||
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
|
||||
scopes, and a sanitized reason for status reporting.
|
||||
|
||||
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
|
||||
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
|
||||
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded
|
||||
remote revocation request. The daemon then removes the local relationship whether the request
|
||||
succeeds or fails. A failed request returns a warning that server-side revocation may remain pending.
|
||||
`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup;
|
||||
the daemon does not retry revocation in the background.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ 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:
|
||||
@@ -37,6 +46,12 @@ 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`
|
||||
@@ -62,6 +77,25 @@ 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
|
||||
@@ -86,6 +120,9 @@ 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
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# 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.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
Create a typecheckable plugin project, install its development dependencies, then install it into
|
||||
the daemon. `init` only writes the project files; it does not run the package manager.
|
||||
|
||||
```bash
|
||||
paseo plugin init /absolute/path/to/my-plugin
|
||||
cd /absolute/path/to/my-plugin
|
||||
npm install
|
||||
npm run typecheck
|
||||
paseo plugin install /absolute/path/to/my-plugin
|
||||
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
|
||||
paseo plugin ls
|
||||
```
|
||||
|
||||
The daemon stores directory sources under the root `plugins` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginsEnabled": true,
|
||||
"plugins": {
|
||||
"my-plugin": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/my-plugin",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
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:
|
||||
|
||||
| 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. |
|
||||
|
||||
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`.
|
||||
|
||||
| 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";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, createGreeting);
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
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";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, search);
|
||||
plugin.addAttachmentSource(issues);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
|
||||
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
@@ -81,6 +81,10 @@ 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.
|
||||
@@ -105,6 +109,13 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
multiple catalog requests must not apply this deadline separately to each request. The manager
|
||||
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
|
||||
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
|
||||
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.
|
||||
@@ -132,6 +143,8 @@ 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
|
||||
@@ -417,6 +430,7 @@ 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>;
|
||||
|
||||
+29
-17
@@ -120,7 +120,7 @@ republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
for package in highlight relay protocol client plugin server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
@@ -464,26 +464,37 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
## Changelog wording
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of
|
||||
product behavior that changed.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
|
||||
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
|
||||
- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`,
|
||||
`Changed <behavior>`, or `Fixed <failure> when <condition>`.
|
||||
- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not
|
||||
broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
|
||||
- **Use concrete product and runtime terms.** Git polling, persisted cache, provider catalog,
|
||||
and WebSocket reconnects can identify the affected behavior. Component names, internal
|
||||
modules, code symbols, and implementation techniques cannot: omit `WorkingIndicator`,
|
||||
`reconcileAndEmitWorkspaceUpdates`, remounts, memoization, and controlled inputs.
|
||||
- **State the consequence only when the change itself is unclear.** Keep the condition that
|
||||
makes the consequence true. Do not replace a precise change with a broad benefit claim.
|
||||
- **Do not invent context.** Mention an upgrade, platform, workload, or user action only when
|
||||
the PR or linked issue establishes that scope.
|
||||
|
||||
| Wrong (implementation-facing) | Right (user-facing) |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
|
||||
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
|
||||
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
|
||||
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
|
||||
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
|
||||
| Added debouncing to the search input | Search results no longer lag behind typing |
|
||||
| Avoid | Write |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
|
||||
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
|
||||
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
|
||||
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior
|
||||
that changed, the failure that was fixed, or the capability that was added? If the bullet only
|
||||
claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
@@ -498,8 +509,7 @@ Every bullet must be scannable at a glance. The changelog is not release documen
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog attribution
|
||||
@@ -567,6 +577,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
### Beta release
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
@@ -587,6 +598,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
|
||||
@@ -24,7 +24,9 @@ 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
|
||||
|
||||
@@ -124,6 +124,16 @@ 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.
|
||||
|
||||
+30
-9
@@ -42,6 +42,16 @@ 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
|
||||
@@ -85,16 +95,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
|
||||
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
|
||||
older-history availability, authority status, or sync generation because those facts would describe
|
||||
the complete source dataset rather than the truncated display dataset.
|
||||
The durable replica cache persists synchronization authority only when it can store the complete
|
||||
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
|
||||
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
|
||||
`after endSeq`, and requests `before startSeq` when the user loads older history.
|
||||
|
||||
Restoring that cache produces a painted timeline: the items may render immediately, but the first
|
||||
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
|
||||
items, range, and older-history availability. Live rows received between cache paint and that tail
|
||||
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
|
||||
reconciled with the authoritative tail and subsequent catch-up.
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
|
||||
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
|
||||
|
||||
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
||||
the authoritative range through the existing forward-page path. The cache does not persist sync
|
||||
generation or unreconciled local submissions.
|
||||
|
||||
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
||||
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
||||
@@ -167,6 +183,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
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
|
||||
|
||||
@@ -178,11 +178,6 @@ 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
@@ -1 +1 @@
|
||||
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
|
||||
sha256-ScFfksximKBaqTyVQ064p/LN3nHTT+pGl3WYF9iuHu4=
|
||||
|
||||
Generated
+630
-50
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -2548,19 +2549,6 @@
|
||||
"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",
|
||||
@@ -6259,6 +6247,10 @@
|
||||
"resolved": "packages/highlight",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/plugin": {
|
||||
"resolved": "packages/plugin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/protocol": {
|
||||
"resolved": "packages/protocol",
|
||||
"link": true
|
||||
@@ -7762,6 +7754,16 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mattermost/react-native-paste-input": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-2.0.1.tgz",
|
||||
"integrity": "sha512-Q9zjXvjwxVQ1M8ylxr9Y+wUpG7xb1wW4u+I34oPvF3etTyBrGtVyA4Bt7Xphgc29eW6wQFUxTIbzZc3E6W2MQg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": ">=0.76.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
@@ -11125,21 +11127,6 @@
|
||||
"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",
|
||||
@@ -11660,6 +11647,32 @@
|
||||
"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",
|
||||
@@ -14115,6 +14128,12 @@
|
||||
"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",
|
||||
@@ -16043,6 +16062,15 @@
|
||||
],
|
||||
"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",
|
||||
@@ -30894,6 +30922,27 @@
|
||||
"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",
|
||||
@@ -36091,7 +36140,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36105,14 +36154,18 @@
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -37139,12 +37192,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/server": "0.3.1",
|
||||
"@getpaseo/client": "0.5.0-beta.2",
|
||||
"@getpaseo/protocol": "0.5.0-beta.2",
|
||||
"@getpaseo/server": "0.5.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37391,10 +37444,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"@getpaseo/protocol": "0.5.0-beta.2",
|
||||
"@getpaseo/relay": "0.5.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37405,7 +37458,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37648,7 +37701,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38544,7 +38597,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38557,13 +38610,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": {
|
||||
@@ -38571,9 +38624,30 @@
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38586,7 +38660,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38804,15 +38878,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/highlight": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.0-beta.2",
|
||||
"@getpaseo/highlight": "0.5.0-beta.2",
|
||||
"@getpaseo/plugin": "0.5.0-beta.2",
|
||||
"@getpaseo/protocol": "0.5.0-beta.2",
|
||||
"@getpaseo/relay": "0.5.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38821,6 +38897,7 @@
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lru-cache": "^11.5.1",
|
||||
@@ -38984,6 +39061,468 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@isaacs/ttlcache": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
|
||||
@@ -39138,6 +39677,47 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
@@ -39360,7 +39940,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+13
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -23,6 +23,7 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -46,19 +47,21 @@
|
||||
"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: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": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"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: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 --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"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",
|
||||
"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",
|
||||
@@ -99,11 +102,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 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/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/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/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/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/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 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: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",
|
||||
|
||||
@@ -4,6 +4,7 @@ const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -139,7 +140,8 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[withAndroidAsyncStorageSize, 10],
|
||||
withPasteInput,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
|
||||
@@ -2,6 +2,7 @@ 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();
|
||||
@@ -31,8 +32,7 @@ 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 page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await openChangesPanel(page);
|
||||
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" });
|
||||
|
||||
@@ -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("button", { name: "Rewind conversation", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Rewind conversation", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectTurnCompletesNormally(
|
||||
|
||||
@@ -24,12 +24,23 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
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 { selectModel } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings, 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,
|
||||
@@ -257,14 +268,20 @@ async function submitMessageThatWillBeRejected(page: Page, prompt: string): Prom
|
||||
|
||||
async function expectRejectedSubmissionRestored(
|
||||
page: Page,
|
||||
input: { prompt: string; errorMessage: string },
|
||||
input: { prompt: string; errorMessage: string; preservesActiveTurn?: boolean },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
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("turn-working-indicator")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -277,6 +294,76 @@ 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);
|
||||
@@ -336,6 +423,66 @@ async function expectInterruptedTurnOrderAfterReconnect(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
|
||||
title: `Workspace eviction ${index + 1}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const prompt = "Keep this hidden image prompt before its streaming output.";
|
||||
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents([
|
||||
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
|
||||
]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
@@ -627,7 +774,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 page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
@@ -801,9 +948,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 interrupt", exact: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Send and steer", 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,
|
||||
@@ -963,6 +1110,115 @@ 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) => {
|
||||
@@ -1013,6 +1269,13 @@ test.describe("Agent message submission", () => {
|
||||
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
|
||||
});
|
||||
|
||||
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import path from "node:path";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: {
|
||||
[provider]: { model, mode: "full-access" },
|
||||
mock: { model: "ten-second-stream", mode: "load-test" },
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
provider: MODELESS_PROVIDER,
|
||||
model: MODELESS_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderModePreference(page: Page, provider: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
({ preferencesKey, providerId }) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.mode ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" });
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_modeless_repair",
|
||||
name: "Approval work",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: MODELESS_PROVIDER,
|
||||
label: "Pi profile test",
|
||||
extends: "pi",
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [
|
||||
{
|
||||
id: MODELESS_MODEL,
|
||||
label: "Pi profile model",
|
||||
description: "Modeless profile regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Approval work");
|
||||
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 })
|
||||
.toBeNull();
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 })
|
||||
.toBe("approval-test");
|
||||
|
||||
await openModelPicker(page);
|
||||
await drillIntoProvider(page, MODELESS_PROVIDER);
|
||||
await page.getByRole("button", { name: /Pi profile model/ }).click();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: { provider: MODELESS_PROVIDER },
|
||||
});
|
||||
expect(createAgentRequest.config).not.toHaveProperty("modeId");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-provider profile features survive immediate workspace creation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" });
|
||||
const featureProviderId = "profile-feature-e2e";
|
||||
const featureModelId = "claude-opus-5";
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_feature_transition",
|
||||
name: "Fast profile",
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: featureProviderId,
|
||||
label: "Profile feature test",
|
||||
models: [
|
||||
{
|
||||
id: featureModelId,
|
||||
label: "Profile feature model",
|
||||
description: "Cross-provider feature regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,14 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
expectComposerDoesNotName,
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectComposerModel,
|
||||
expectModelRowSelected,
|
||||
expectNothingSelectedInPickerRoot,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfilePinnedAboveProviders,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
@@ -74,9 +71,12 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("the profile is pinned above the provider list", async () => {
|
||||
await test.step("the sole provider opens directly", async () => {
|
||||
await openModelPicker(page);
|
||||
await expectProfilePinnedAboveProviders(page, {
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expect(page.getByTestId("sheet-header-back")).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="model-provider-"]')).toHaveCount(0);
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
@@ -100,17 +100,9 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await expectComposerDoesNotName(page, PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("reopening the picker shows no profile marked as selected", async () => {
|
||||
await test.step("reopening returns directly to the provider models", async () => {
|
||||
await openModelPicker(page);
|
||||
await expectProfilePinnedAboveProviders(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectNothingSelectedInPickerRoot(page);
|
||||
});
|
||||
|
||||
await test.step("the model it materialized is the one marked selected", async () => {
|
||||
await drillIntoProvider(page, "mock");
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||
|
||||
@@ -26,9 +26,41 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectOneTailCheckWithoutCatchUpPages,
|
||||
expectOneResumeCheckWithoutTail,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
restoreViewedTimelineWithHeldTail,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline resume", () => {
|
||||
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePromptVisible(page, background.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, background.firstPrompt);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requests);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectOneResumeCheckWithoutTail(gate, requests);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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(22, 22, 22)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
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 interface font size to settings text", async ({ page }) => {
|
||||
test("applies the base font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
@@ -56,11 +56,11 @@ test("applies the interface 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("Interface font size");
|
||||
await expect(fontSizeInput).toHaveValue("24");
|
||||
const fontSizeInput = page.getByLabel("Base font size");
|
||||
await expect(fontSizeInput).toHaveValue("21");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "9px");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
archiveFinishedSubagents,
|
||||
expectArchiveFinishedInProgress,
|
||||
expectArchiveFinishedRetry,
|
||||
expectManagedSubagentArchived,
|
||||
expectManagedSubagentUnarchived,
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
holdManagedSubagentArchiveRequest,
|
||||
openSubagentsTrack,
|
||||
rejectNextManagedSubagentArchiveRequest,
|
||||
seedParentWithSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
|
||||
test.describe("Archive finished subagents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("archives a finished managed child from the track", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Archive parent",
|
||||
childTitle: "Finished child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await archiveGate.waitForRequest();
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectArchiveFinishedInProgress(page, 0, 1);
|
||||
await expectManagedSubagentUnarchived(workspace, agents.child.id);
|
||||
archiveGate.release();
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
|
||||
test("restores a failed child and retries the archive", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Retry parent",
|
||||
childTitle: "Retry child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await rejection.waitForRejection();
|
||||
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
await expectArchiveFinishedRetry(page, 1, 1);
|
||||
await archiveFinishedSubagents(page);
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
});
|
||||
@@ -90,11 +90,7 @@ async function openTabSwitcher(page: Page) {
|
||||
async function openModelSelector(page: Page) {
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
await expectBottomSheetOpen(page);
|
||||
await expect(
|
||||
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function openAndCloseTabSwitcherTwice(page: Page) {
|
||||
@@ -124,15 +120,23 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector closes after model selection", async ({ page }) => {
|
||||
test("search selection returns to configuration options", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await sheet.getByText("Ten second stream", { exact: true }).click();
|
||||
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 expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
@@ -12,17 +13,15 @@ import {
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId,
|
||||
provider: "mock",
|
||||
providerPreferences: {
|
||||
mock: {
|
||||
@@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
mode: "load-test",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -13,7 +15,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 page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -33,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -50,13 +52,15 @@ 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("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommitDiffHeaderGeometry(panel);
|
||||
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
|
||||
await expect(panel.locator('[data-testid^="diff-code-row-"]')).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();
|
||||
@@ -67,7 +71,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("diff-code-row-0")).toBeVisible();
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
@@ -84,3 +88,25 @@ 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);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -294,6 +295,28 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "file-create-escape-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running while a file draft is cancelled.",
|
||||
});
|
||||
try {
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await nameInput.press("Escape");
|
||||
|
||||
await expect(nameInput).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -544,6 +544,88 @@ 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);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 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;
|
||||
@@ -0,0 +1,587 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,10 +38,7 @@ function fitsViewportWidth(element: HTMLElement): boolean {
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
await editor(page).fill(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
@@ -154,9 +151,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
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 expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
@@ -347,14 +343,13 @@ 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 page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
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");
|
||||
@@ -441,20 +436,21 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.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(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
await expect(visibleFilePane.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(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -110,6 +111,21 @@ 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,
|
||||
@@ -124,6 +140,14 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
||||
|
||||
await page.getByTestId("files-new-folder").click();
|
||||
await expect(nameInput).toBeVisible();
|
||||
const draftRow = nameInput.locator("xpath=..");
|
||||
const placeholderColor = await nameInput.evaluate(
|
||||
(input) => getComputedStyle(input, "::placeholder").color,
|
||||
);
|
||||
const extraMutedChevronColor = await draftRow
|
||||
.locator("svg")
|
||||
.evaluate((icon) => getComputedStyle(icon).stroke);
|
||||
expect(placeholderColor).toBe(extraMutedChevronColor);
|
||||
await nameInput.press("Tab");
|
||||
await expect(nameInput).toBeHidden();
|
||||
|
||||
@@ -189,18 +213,38 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
||||
const fileRow = entry("created.txt").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(folderRow.locator("svg")).toHaveCount(2);
|
||||
await expect(folderRow.locator("svg")).toHaveCount(1);
|
||||
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||
const gitRow = entry(".git").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
|
||||
const collapsedChevronSlotBounds = await gitRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
const expandedChevronSlotBounds = await folderRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
expect(collapsedChevronBounds).not.toBeNull();
|
||||
expect(expandedChevronBounds).not.toBeNull();
|
||||
expect(collapsedChevronSlotBounds).not.toBeNull();
|
||||
expect(expandedChevronSlotBounds).not.toBeNull();
|
||||
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
||||
expect(collapsedChevronCenter).toBeCloseTo(
|
||||
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
expect(expandedChevronCenter).toBeCloseTo(
|
||||
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await entry("folder").boundingBox();
|
||||
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
@@ -409,7 +453,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 page.getByTestId("explorer-tab-changes").click();
|
||||
await openChangesPanel(page);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,18 @@ import {
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
openNewTabMenuWithShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
waitForTabBar,
|
||||
pressNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
expectTabTitleFits,
|
||||
terminalSurfaceLocator,
|
||||
} from "../support/helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
||||
@@ -20,13 +24,37 @@ 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 () => {
|
||||
@@ -38,12 +66,66 @@ test.afterAll(async () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab creation", () => {
|
||||
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
||||
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 }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||
|
||||
await expectComposerVisible(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();
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
@@ -51,20 +133,45 @@ test.describe("Tab creation", () => {
|
||||
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countBefore + 1);
|
||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||
|
||||
// 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 pressDirectNewTabShortcut(page, "a");
|
||||
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);
|
||||
|
||||
@@ -90,12 +197,45 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -197,7 +337,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), 2_000, 30);
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
@@ -207,11 +347,15 @@ 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((s) => s.length);
|
||||
const counts = snapshots.map((snapshot) => snapshot.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,6 +34,8 @@ 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,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
@@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
});
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
@@ -85,4 +85,52 @@ 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,4 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
@@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => {
|
||||
// control and silently change that (possibly running) agent's mode — e.g. into a
|
||||
// permissive/bypass mode. See use-keyboard-action-handler.ts.
|
||||
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
|
||||
await seedCodexDefaultPreferences(page, serverId);
|
||||
await seedCodexDefaultPreferences(page);
|
||||
const modeRequests = await recordSetAgentModeRequests(page);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
function observePluginCatalog(page: Page) {
|
||||
let responses = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown };
|
||||
};
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
if (message?.type === "plugin.catalog.get.response") responses += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForInitialFetch: async () => {
|
||||
await expect.poll(() => responses).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSource(title: string, renderError?: string): string {
|
||||
return `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(plugin) {
|
||||
function Surface() {
|
||||
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
|
||||
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
|
||||
return <Text>${title} cleanup {cleanups}</Text>;
|
||||
}
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
|
||||
${
|
||||
renderError
|
||||
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
|
||||
plugin.addSurface("healthy", HealthySurface);
|
||||
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
|
||||
: ""
|
||||
}
|
||||
return () => {
|
||||
const storage = globalThis.sessionStorage;
|
||||
if (storage) {
|
||||
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
|
||||
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
|
||||
}
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
async function openPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expectSettingsHeader(page, "Plugins");
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
|
||||
await expect(body).toHaveCount(1);
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
|
||||
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function leavePluginSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/settings\//);
|
||||
}
|
||||
|
||||
async function openContributionFromSettings(
|
||||
page: Page,
|
||||
title: string,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await leavePluginSettings(page);
|
||||
await openContribution(page, title, expectedBody);
|
||||
}
|
||||
|
||||
async function reloadActiveContribution(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await client.reloadPlugin("e2e-plugin");
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByLabel("Plugin installation ID").focus();
|
||||
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
} finally {
|
||||
await client.removePlugin("e2e-plugin").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
commitPaseoConfig,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectProjectHostContextHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectNoUncommittedSetupWarning,
|
||||
expectProjectEditFailed,
|
||||
expectProjectEditName,
|
||||
expectProjectEditSaved,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
expectUncommittedSetupWarning,
|
||||
fillProjectIconUrl,
|
||||
fillProjectName,
|
||||
installDaemonConnectionGate,
|
||||
@@ -42,12 +45,19 @@ 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"];
|
||||
|
||||
@@ -216,9 +226,16 @@ test.describe("Projects settings", () => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
await expectUncommittedSetupWarning(page);
|
||||
|
||||
commitPaseoConfig(editableProject.path);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
@@ -311,6 +328,33 @@ 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,
|
||||
@@ -439,5 +483,7 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,21 +21,22 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
|
||||
async function openProviderSettingsFromModelSelector(page: Page) {
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const configuration = page.getByTestId("agent-controls-model-sheet");
|
||||
await expect(configuration).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("agent-controls-model").click();
|
||||
|
||||
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
|
||||
if (await openCodeRow.isVisible().catch(() => false)) {
|
||||
await openCodeRow.click();
|
||||
}
|
||||
const modelBrowser = page.getByTestId("agent-controls-model-browser-sheet");
|
||||
await expect(modelBrowser).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectModelSelectorVisible(page: Page) {
|
||||
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
|
||||
async function expectModelBrowserVisible(page: Page) {
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeTopSheet(page: Page) {
|
||||
@@ -180,7 +181,7 @@ test.describe("provider settings overlay stack", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
test("provider settings and children close back through the model browser to configuration", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -192,13 +193,18 @@ test.describe("provider settings overlay stack", () => {
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeSheetByHeaderButton(page, "provider-settings-sheet");
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await expectModelBrowserVisible(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 expectModelSelectorVisible(page);
|
||||
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 closeTopSheet(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -89,6 +89,9 @@ 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) {
|
||||
@@ -133,6 +136,8 @@ 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();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, prompt),
|
||||
)
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.evaluate((messageText) => {
|
||||
const key = "@paseo:replica-cache";
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) throw new Error("Replica cache was not persisted");
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === messageText);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
localStorage.setItem(key, JSON.stringify(cache));
|
||||
}, prompt);
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) throw new Error("Replica cache was not persisted");
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === prompt);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
await writeReplicaCache(page, cache);
|
||||
}
|
||||
|
||||
async function waitForCurrentSubmissionExcludedFromCache(
|
||||
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === prompt &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
|
||||
title: "Rewind cache upgrade e2e",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
let heldTimelineRequest = false;
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectUserMessageVisible(page, prompt);
|
||||
await gate.drop();
|
||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
||||
gate.holdNextClientRequest("fetch_agent_timeline_request");
|
||||
await page.reload();
|
||||
await gate.waitForHeldClientRequest();
|
||||
heldTimelineRequest = true;
|
||||
|
||||
const restoredMessage = userMessage(page, prompt);
|
||||
await expect(restoredMessage).toBeVisible();
|
||||
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
} finally {
|
||||
if (heldTimelineRequest) gate.releaseHeldClientRequest();
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
|
||||
import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs";
|
||||
import { expectExplorerEntryVisible, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
openChangesPanel,
|
||||
} 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:
|
||||
// - The right sidebar (file browser / git changes) reads the directory, so it
|
||||
// is IDENTICAL across same-directory workspaces.
|
||||
// - Files and Changes read the directory, so their content 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,
|
||||
@@ -41,7 +35,7 @@ async function createSecondWorkspaceOnSameDir(
|
||||
test.describe("Same-directory workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({
|
||||
test("directory-backed Files and Changes content is shared across same-dir workspaces", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" });
|
||||
@@ -71,19 +65,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 openExplorerTab(page, "files");
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME change is visible. The right
|
||||
// sidebar content does not differ between the two views.
|
||||
// Workspace B (same directory): the SAME directory-backed content is
|
||||
// visible even though the panel tabs belong to a different workspace.
|
||||
await gotoWorkspace(page, secondWorkspaceId);
|
||||
await openExplorerTab(page, "files");
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async (
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -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("button", { name: "简体中文 - Simplified Chinese", exact: true }).click();
|
||||
await page.getByRole("menuitem", { 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("button", { name: "English - 英语", exact: true }).click();
|
||||
await page.getByRole("menuitem", { 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("button", { name: "한국어 - Korean", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "한국어 - Korean", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("기본 전송", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "한국어", exact: true }).click();
|
||||
await page.getByRole("button", { name: "English - 영어", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "English - 영어", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildOpenProjectRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
@@ -181,6 +182,28 @@ 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",
|
||||
expected: "interrupt" | "queue" | "steer",
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) {
|
||||
return box;
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
async function pressProjectRow(rows: Locator) {
|
||||
await rows.page().mouse.down();
|
||||
}
|
||||
|
||||
async function pressWorkspaceRow(rows: Locator) {
|
||||
const solidScrimStop = rows
|
||||
.nth(0)
|
||||
.getByTestId("sidebar-workspace-trailing-scrim")
|
||||
.locator("stop")
|
||||
.nth(1);
|
||||
const hoverScrimColor = await solidScrimStop.getAttribute("stop-color");
|
||||
await rows.page().mouse.down();
|
||||
await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor);
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(
|
||||
rows: Locator,
|
||||
pressRow: (rows: Locator) => Promise<void>,
|
||||
) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
|
||||
await pressRow(rows);
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await expect(trailingScrim).toHaveCount(0);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
@@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
|
||||
pressProjectRow,
|
||||
);
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
@@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
|
||||
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
|
||||
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
|
||||
const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
} finally {
|
||||
await firstProject.cleanup();
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
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) {
|
||||
async function expectBorderHighlight(
|
||||
page: Page,
|
||||
testID: string,
|
||||
hoverTarget: Locator,
|
||||
expectedWidth: string,
|
||||
) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await hoverTarget.hover();
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", "1px");
|
||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle");
|
||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expectBorderHighlight(page, "explorer-sidebar-resize-handle");
|
||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
||||
await expectBorderHighlight(
|
||||
page,
|
||||
"workspace-split-resize-handle",
|
||||
splitHandle.getByRole("separator"),
|
||||
"3px",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
|
||||
@@ -331,7 +332,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("yields app navigation to the Explorer", async ({ page }) => {
|
||||
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
@@ -339,44 +340,19 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
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 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);
|
||||
|
||||
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),
|
||||
await explorerToggle.click();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
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();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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,6 +2,7 @@ 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;
|
||||
@@ -82,7 +83,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 page.getByRole("button", { name: "Split pane down" }).first().click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
||||
|
||||
// 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,7 +56,10 @@ async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSiz
|
||||
}
|
||||
|
||||
async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
// 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-terminal").click();
|
||||
}
|
||||
|
||||
@@ -124,7 +127,9 @@ 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")).toBeVisible({
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -64,9 +65,8 @@ 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,15 +80,14 @@ async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (!secondRunningMessage || secondRunningForwarded) {
|
||||
return;
|
||||
if (secondRunningMessage) {
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningMessage = null;
|
||||
}
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningForwarded = true;
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseServerMessages) {
|
||||
if (pauseAfterSecondRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,41 +96,24 @@ async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
if (toolCall.status === "running" || toolCall.status === "executing") {
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (toolCall.callId === secondCallId) {
|
||||
const transformedMessage =
|
||||
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
|
||||
secondRunningMessage = transformedMessage;
|
||||
pauseAfterSecondRunning = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
@@ -148,9 +130,7 @@ async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
if (secondRunningMessage) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
forwardSecondRunning?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -196,10 +176,11 @@ 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");
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
const idleGroupHandle = await group.elementHandle();
|
||||
if (!idleGroupHandle) {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -150,7 +151,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await page.getByRole("button", { name: "Split pane right" }).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await moveActiveTabRight(page);
|
||||
await expect(
|
||||
|
||||
@@ -3,7 +3,10 @@ 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 { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
@@ -56,7 +59,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
@@ -99,7 +102,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await createAgentTabFromMenu(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
|
||||
@@ -43,7 +43,9 @@ 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 })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Changes", { exact: true }).filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar shortcuts outside a workspace preserve its focus mode", async ({
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
|
||||
@@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port:
|
||||
{
|
||||
daemon: host,
|
||||
preferences: {
|
||||
serverId: input.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies FormPreferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -24,14 +25,8 @@ 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> {
|
||||
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 openFilesPanel(page);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ 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,6 +9,7 @@ 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,
|
||||
@@ -65,7 +66,7 @@ test.describe("Workspace pane mounting", () => {
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
|
||||
await page.getByRole("button", { name: "Split pane right" }).first().click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
2,
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import readline from "node:readline";
|
||||
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
|
||||
for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
provider: "e2e",
|
||||
id: "pi-profile-model",
|
||||
name: "Pi profile model",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (command.type === "get_state") {
|
||||
data = {
|
||||
sessionId: "e2e-pi-session",
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ const test = metroTest.extend<
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
const createAgentPreferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 {
|
||||
@@ -28,6 +30,8 @@ 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;
|
||||
|
||||
@@ -104,21 +104,24 @@ export interface SeededProviderModel {
|
||||
* list is what avoids running anything: replacement models skip catalog
|
||||
* discovery, and Claude's static modes mean the provider never spawns. The
|
||||
* command only has to resolve for the availability probe, hence `node`.
|
||||
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
|
||||
*/
|
||||
export async function seedModelProvider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
models: SeededProviderModel[];
|
||||
extends?: "claude" | "pi";
|
||||
command?: string[];
|
||||
}): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[input.id]: {
|
||||
extends: "claude",
|
||||
extends: input.extends ?? "claude",
|
||||
label: input.label,
|
||||
description: `${input.label} test provider`,
|
||||
enabled: true,
|
||||
command: ["node"],
|
||||
command: input.command ?? ["node"],
|
||||
models: input.models,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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();
|
||||
}
|
||||
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
|
||||
export interface TimelineRequestTracker {
|
||||
nextRequest(): Promise<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
requests(): Array<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
waitForResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function trackAgentTimelineRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<TimelineRequestTracker> {
|
||||
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
|
||||
const seen: Request[] = [];
|
||||
const waiters: Array<(request: Request) => void> = [];
|
||||
let responseSeen = false;
|
||||
let resolveResponse: (() => void) | null = null;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
const rawCursor = sessionMessage.cursor;
|
||||
const cursor =
|
||||
rawCursor &&
|
||||
typeof rawCursor === "object" &&
|
||||
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
|
||||
typeof (rawCursor as { seq?: unknown }).seq === "number"
|
||||
? {
|
||||
epoch: (rawCursor as { epoch: string }).epoch,
|
||||
seq: (rawCursor as { seq: number }).seq,
|
||||
}
|
||||
: null;
|
||||
const request = {
|
||||
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
|
||||
cursor,
|
||||
};
|
||||
seen.push(request);
|
||||
waiters.shift()?.(request);
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
responseSeen = true;
|
||||
resolveResponse?.();
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
nextRequest() {
|
||||
const request = seen[0];
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => waiters.push(resolve));
|
||||
},
|
||||
requests: () => [...seen],
|
||||
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
|
||||
};
|
||||
}
|
||||
|
||||
export async function trackPromptJumpRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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("button", { name: "Fork in a new tab" })).toBeVisible({
|
||||
await expect(page.getByRole("menuitem", { 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("button", { name: "Fork in a new tab" }).click();
|
||||
await page.getByRole("menuitem", { 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("button", { name: "Fork in a new tab" }).click();
|
||||
await page.getByRole("menuitem", { name: "Fork in a new tab" }).click();
|
||||
}
|
||||
|
||||
export async function forkMostRecentAssistantTurnToNewWorkspace(page: Page): Promise<void> {
|
||||
await openMostRecentAssistantForkMenu(page);
|
||||
await page.getByRole("button", { name: "Fork in a new workspace" }).click();
|
||||
await page.getByRole("menuitem", { 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 git diff panel's Changes header (right-side
|
||||
// ExplorerSidebar), not in the workspace header. It renders as a button whose
|
||||
// The branch switcher lives in the Changes tab, 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,17 +16,9 @@ function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
.first();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
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 openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user