mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb2b40855 | ||
|
|
c062278595 | ||
|
|
b9cdfb6528 | ||
|
|
1361da5ef6 | ||
|
|
3377ffed29 | ||
|
|
c56638ea8c | ||
|
|
185ab3f300 | ||
|
|
1694828275 | ||
|
|
74a377ff6a | ||
|
|
63923fdd07 | ||
|
|
11e76f6655 | ||
|
|
4a3e6dea63 | ||
|
|
f35d860725 | ||
|
|
95f4eb7577 | ||
|
|
fcd81f591e | ||
|
|
b0fe4efc8c | ||
|
|
f2f93087ed | ||
|
|
73a5e6c63e | ||
|
|
fa53ba8f93 | ||
|
|
375e9b3e53 | ||
|
|
adf80a9fe0 | ||
|
|
8533c5c06f | ||
|
|
b1050d9bed | ||
|
|
5e561f8a23 | ||
|
|
ce335de1cd | ||
|
|
6688c92bc6 | ||
|
|
8cf5942a5f | ||
|
|
9113542c5a | ||
|
|
2618686677 | ||
|
|
86ef468003 | ||
|
|
0dad1d7a61 | ||
|
|
63587a9c03 | ||
|
|
a88aa49f4c | ||
|
|
eef9be8863 | ||
|
|
650f3ca7f6 | ||
|
|
b6bfdedf58 | ||
|
|
1d4c3a2722 | ||
|
|
6c813d1a91 | ||
|
|
aeb240be98 | ||
|
|
7080648e17 | ||
|
|
463415ae84 | ||
|
|
693280d996 | ||
|
|
3c6e4e39ec | ||
|
|
ed628ff82e | ||
|
|
667664847d | ||
|
|
7ea4288117 | ||
|
|
42b6d99d60 | ||
|
|
23debf3af1 | ||
|
|
25eb322ef7 | ||
|
|
deb846eed6 | ||
|
|
a1ed5a8bad | ||
|
|
3d69b0f458 | ||
|
|
51a435d3cf | ||
|
|
fb67d2cdc8 | ||
|
|
fe803f6e70 | ||
|
|
a501e1e051 | ||
|
|
1fc5250544 | ||
|
|
1860a6f3af | ||
|
|
b6641d7066 | ||
|
|
5c7d62df8c | ||
|
|
96cb70f5ce | ||
|
|
a8734a9724 | ||
|
|
cda441d3b2 | ||
|
|
2d7896999c | ||
|
|
12e3acd188 | ||
|
|
8e0af11b66 | ||
|
|
3377997f30 | ||
|
|
7ae5133ed2 | ||
|
|
b6d18d0c01 | ||
|
|
4e52125f5b | ||
|
|
f82886f284 | ||
|
|
b0ce40804d | ||
|
|
2755596b9f | ||
|
|
657152c47a | ||
|
|
4c46f20179 | ||
|
|
da930b4c7d | ||
|
|
ecfb77b014 | ||
|
|
4de913fb82 | ||
|
|
0ad69d3eb0 | ||
|
|
46b401eac6 | ||
|
|
96cf0f2ae5 | ||
|
|
59e6232784 | ||
|
|
d46267aed6 | ||
|
|
5a38a33345 | ||
|
|
5c9a04d333 | ||
|
|
d3b10f0299 | ||
|
|
0f68e55eca | ||
|
|
579c6a58f5 | ||
|
|
f59db03d67 | ||
|
|
fd22784e21 | ||
|
|
f6288629ff | ||
|
|
4a5716360b | ||
|
|
e22ad117fd | ||
|
|
e19d3914ae | ||
|
|
996d4ec526 | ||
|
|
20d7efc46a | ||
|
|
59aa8433d4 | ||
|
|
4bfb682cd9 | ||
|
|
c4dc701411 | ||
|
|
f73f65cd78 | ||
|
|
bffb33ed32 | ||
|
|
8bfae15855 | ||
|
|
c778c47b30 | ||
|
|
6da9fa43fa | ||
|
|
cd8c868b32 | ||
|
|
6f6466d4cb | ||
|
|
6147991519 | ||
|
|
bbe3f17327 | ||
|
|
c914bcb44b | ||
|
|
54299a2e6c | ||
|
|
f4b5f32862 | ||
|
|
7db654aa0a | ||
|
|
4092c29265 | ||
|
|
5cedc210a8 | ||
|
|
4724d1d9b7 | ||
|
|
8fdca94ead | ||
|
|
88f410567d | ||
|
|
f998ee1691 | ||
|
|
7a47ec0ee9 | ||
|
|
4f796181bf | ||
|
|
f517493591 | ||
|
|
fb74f766c8 | ||
|
|
e268d1a894 | ||
|
|
b5f5832214 | ||
|
|
b8a31034ca | ||
|
|
68275f1e0f | ||
|
|
3979371901 | ||
|
|
71e375c2bc | ||
|
|
35cde075ae | ||
|
|
2124115c8e | ||
|
|
4094eeb566 | ||
|
|
8d92e1cc17 | ||
|
|
5123b23f41 | ||
|
|
c3594b2079 | ||
|
|
dc9fb9aa1b | ||
|
|
7c430777bf | ||
|
|
acad9f6881 | ||
|
|
fc214b20f1 | ||
|
|
c998b24c5f | ||
|
|
28df3f259a | ||
|
|
cc2012be7c | ||
|
|
b139046a3e | ||
|
|
482bbb2910 | ||
|
|
4a5a8d469c | ||
|
|
c60fa098ac | ||
|
|
33cf6cf337 | ||
|
|
65fbe38dd3 | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 | ||
|
|
8f0c35e924 |
@@ -348,12 +348,12 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Build native app dependency
|
||||
run: npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
|
||||
+118
@@ -1,5 +1,123 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.1 - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
|
||||
|
||||
## 0.7.0 - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
||||
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
|
||||
|
||||
### Added
|
||||
|
||||
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
||||
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
|
||||
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
|
||||
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
|
||||
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
|
||||
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
|
||||
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
|
||||
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
||||
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
|
||||
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
||||
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
||||
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
||||
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
|
||||
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
||||
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
|
||||
|
||||
### Improved
|
||||
|
||||
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
||||
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
||||
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
|
||||
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
|
||||
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
|
||||
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
|
||||
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
|
||||
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
|
||||
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
|
||||
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
|
||||
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
|
||||
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
|
||||
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
|
||||
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
|
||||
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
|
||||
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
|
||||
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
|
||||
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
|
||||
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
|
||||
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
|
||||
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
|
||||
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
|
||||
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
||||
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
||||
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||
- Fixed ACP agents reporting the terminal capability as unavailable ([#3910](https://github.com/getpaseo/paseo/pull/3910) by [@pmilanez](https://github.com/pmilanez))
|
||||
- Fixed canceled Claude permission requests leaving stale permission cards ([#3792](https://github.com/getpaseo/paseo/pull/3792))
|
||||
- Fixed plugin daemon sessions hiding agents from non-legacy providers ([#3902](https://github.com/getpaseo/paseo/pull/3902) by [@omercnet](https://github.com/omercnet))
|
||||
- Fixed manually admitted desktop updates disappearing during readiness checks ([#3865](https://github.com/getpaseo/paseo/pull/3865))
|
||||
- Fixed mobile Changes and pull-request actions opening outside Explorer ([#3867](https://github.com/getpaseo/paseo/pull/3867))
|
||||
- Fixed older desktop builds deleting settings written by newer builds ([#3909](https://github.com/getpaseo/paseo/pull/3909) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed composer height flashing after returning from Settings ([#3943](https://github.com/getpaseo/paseo/pull/3943))
|
||||
- Fixed selected workspaces having no visible highlight in the Light theme ([#3922](https://github.com/getpaseo/paseo/pull/3922) by [@wdaubney](https://github.com/wdaubney))
|
||||
- Fixed the scrolling diff order disagreeing with the Changes tree ([#3913](https://github.com/getpaseo/paseo/pull/3913) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed incorrect Spanish translations for Close actions ([#3934](https://github.com/getpaseo/paseo/pull/3934) by [@antonio](https://github.com/antonio))
|
||||
|
||||
## 0.6.1 - 2026-08-25
|
||||
|
||||
### Improved
|
||||
|
||||
- Command Center now matches partial multi-word queries in any order and ranks visible results by relevance ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed upgrades placing persisted Side panel tabs inside Explorer instead of an ordinary side pane ([#3861](https://github.com/getpaseo/paseo/pull/3861))
|
||||
- Fixed Escape leaving Settings open on desktop and web ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed branch switches hiding the current pull request or arming auto-archive from an already-merged pull request ([#3799](https://github.com/getpaseo/paseo/pull/3799))
|
||||
|
||||
## 0.6.0 - 2026-08-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
|
||||
### Added
|
||||
|
||||
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
|
||||
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
|
||||
|
||||
## 0.5.2 - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||
|
||||
## 0.5.1 - 2026-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
@@ -53,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
|
||||
@@ -2,670 +2,210 @@ Copyright (c) 2025-present Mohamed Boudra
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All third party components incorporated into the Paseo Software are
|
||||
licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
* All content outside of the above mentioned restrictions is available
|
||||
under the "AGPLv3" license as defined below.
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
* Third-party components incorporated into Paseo remain licensed under
|
||||
the licenses provided by their respective copyright holders.
|
||||
* Except for those third-party components, Paseo is licensed under the
|
||||
Apache License, Version 2.0, reproduced below.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
+1
-2
@@ -154,9 +154,8 @@ npm run typecheck
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
+1
-1
@@ -169,4 +169,4 @@ npm run typecheck
|
||||
|
||||
## 라이선스
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
@@ -111,8 +111,8 @@ paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
# run on a remote daemon; --cwd is a path on that host
|
||||
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
@@ -188,9 +188,8 @@ npm run typecheck
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
+1
-2
@@ -154,7 +154,6 @@ npm run typecheck
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
@@ -205,4 +204,4 @@ server {
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
+19
-15
@@ -103,9 +103,9 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. 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.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
|
||||
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
||||
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `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/`
|
||||
@@ -113,17 +113,21 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- 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 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. The row store 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. Agents, workspaces, and projects have individual rows; the
|
||||
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
|
||||
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
|
||||
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
|
||||
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
|
||||
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
|
||||
IndexedDB. Native builds use expo-sqlite.
|
||||
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
|
||||
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
|
||||
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
|
||||
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
|
||||
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
|
||||
reads directory rows for that host and establishes its live subscription when connected. Accepted
|
||||
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
|
||||
host connection do not create directory demand or install replicas. The directory owner retains
|
||||
declared surface demand and re-establishes network reconciliation after reconnect; React does not
|
||||
track connection generations. Late cache reads cannot replace state already advanced by live or
|
||||
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
|
||||
checkpoint share one storage transaction. See
|
||||
[data-model.md](data-model.md#replica-row-store)
|
||||
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
|
||||
resume behavior.
|
||||
|
||||
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
|
||||
@@ -158,7 +162,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
@@ -98,6 +98,14 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
### Retained panel measurements
|
||||
|
||||
Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout.
|
||||
|
||||
- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again.
|
||||
- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry.
|
||||
- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames.
|
||||
|
||||
## Naming
|
||||
|
||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||
|
||||
+19
-15
@@ -393,6 +393,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
},
|
||||
"params": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"rpcTimeoutMs": 60000,
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
"slowModel": "anthropic/claude-opus-4-1",
|
||||
"planModel": "openai/o3"
|
||||
@@ -403,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
@@ -416,7 +417,8 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
"sessionDir": "~/.my-pi-fork/sessions",
|
||||
"rpcTimeoutMs": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +426,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
|
||||
---
|
||||
|
||||
@@ -501,25 +503,25 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
}
|
||||
```
|
||||
|
||||
ACP agents execute filesystem and terminal operations in their own environment
|
||||
by default. To let a compliant agent delegate those operations to Paseo instead,
|
||||
enable the corresponding client capabilities:
|
||||
ACP agents execute filesystem operations in their own environment by default,
|
||||
while terminal operations run through Paseo on the host. To customize which
|
||||
operations Paseo handles, configure client capabilities in provider params:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"local-agent": {
|
||||
"container-agent": {
|
||||
"extends": "acp",
|
||||
"label": "Local Agent",
|
||||
"command": ["local-agent", "acp"],
|
||||
"label": "Container Agent",
|
||||
"command": ["container-agent", "acp"],
|
||||
"params": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": true,
|
||||
"writeTextFile": true
|
||||
"readTextFile": false,
|
||||
"writeTextFile": false
|
||||
},
|
||||
"terminal": true
|
||||
"terminal": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,9 +530,11 @@ enable the corresponding client capabilities:
|
||||
}
|
||||
```
|
||||
|
||||
Only enable capabilities Paseo should execute. When the agent and Paseo run in
|
||||
different environments, configure equivalent absolute workspace paths before
|
||||
delegating filesystem or terminal operations to Paseo.
|
||||
When an agent runs in a container or remote environment that manages its own
|
||||
terminal, set `terminal: false` to keep command execution inside the agent
|
||||
container. When delegating filesystem operations to Paseo (`fs.readTextFile: true`
|
||||
or `fs.writeTextFile: true`), ensure the agent and Paseo share equivalent
|
||||
absolute workspace paths.
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ $PASEO_HOME/
|
||||
├── runtime/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
├── plugins/
|
||||
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -251,6 +254,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
|
||||
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
|
||||
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
|
||||
Paseo writes it atomically. An update creates and validates a new version directory before changing
|
||||
the configured directory path; successful activation removes the old version.
|
||||
|
||||
### Profile lists
|
||||
|
||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
||||
@@ -521,6 +530,26 @@ Right-sidebar client state splits on whether it is determined by the directory o
|
||||
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
||||
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
||||
|
||||
### Replica row store
|
||||
|
||||
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
|
||||
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
|
||||
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
|
||||
and have at most one row per host.
|
||||
|
||||
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
||||
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
||||
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
|
||||
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
||||
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
||||
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
||||
checkpoint are also applied in one transaction.
|
||||
|
||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
||||
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
||||
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
|
||||
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
|
||||
|
||||
### Draft Store
|
||||
|
||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||
|
||||
+7
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
||||
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
||||
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
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.
|
||||
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
+18
-4
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
||||
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 Side panel toggle, run the app against the root checkout's daemon and use:
|
||||
For the desktop Explorer sidebar 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 Side panel pane,
|
||||
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
|
||||
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
|
||||
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.
|
||||
|
||||
### Preview Windows and Linux window controls on macOS
|
||||
|
||||
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
||||
|
||||
```bash
|
||||
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
||||
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
||||
```
|
||||
|
||||
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
@@ -545,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
```
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Explorer sidebar and side pane
|
||||
|
||||
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
||||
contracts.
|
||||
|
||||
| Surface | Purpose | Lifecycle |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
|
||||
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Callers request the content and never choose the shell.
|
||||
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
||||
routes later presses to the working diff through the shared diff preference.
|
||||
|
||||
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
lifecycle.
|
||||
|
||||
## Side pane
|
||||
|
||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||
|
||||
Placement intent still controls existing tabs:
|
||||
|
||||
| Mode | New target | Existing target |
|
||||
| --------- | --------------------------- | --------------------------------- |
|
||||
| `pane` | opens in the requested pane | moves to the requested pane |
|
||||
| `prefer` | opens in the requested pane | stays where the user placed it |
|
||||
| `focused` | opens in the focused pane | focuses it where it already lives |
|
||||
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
||||
|
||||
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
||||
new target and never yanks an existing tab out of a user-selected pane.
|
||||
|
||||
## Routing preferences
|
||||
|
||||
Desktop **Settings → Layout → Open location** has independent Main panel or On the side choices for
|
||||
Explorer Files, diffs, chat files, files opened from diffs, and subagents. They default to Main
|
||||
panel. Mobile ignores them.
|
||||
|
||||
Pull requests have a three-way open location: Main panel, On the side, or Explorer sidebar. Explorer
|
||||
sidebar is the default. Compact layouts always open pull requests in Explorer regardless of this
|
||||
desktop preference.
|
||||
|
||||
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||
specific shell inside a panel.
|
||||
+3
-1
@@ -67,7 +67,9 @@ that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
against the wrong deck and disappear offscreen. Follow the
|
||||
[retained panel measurement rules](coding-standards.md#retained-panel-measurements)
|
||||
for components that intentionally remain mounted while hidden.
|
||||
|
||||
Hidden host routes may keep their local params while an app-wide route is
|
||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||
|
||||
+12
-21
@@ -132,30 +132,21 @@ quietly relying on:
|
||||
|
||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
Move the chat surface with worklet-driven bottom padding from
|
||||
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
|
||||
and native hit testing in the same layout. Do not translate the stream and
|
||||
composer independently.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||
the composer uses**:
|
||||
Do not use the controller's raw keyboard progress for the padding. It can retain
|
||||
a nonzero value after the keyboard closes. The shared provider normalizes that
|
||||
state and reconciles native animation-end events.
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed. Do not call
|
||||
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
`measureInWindow` already includes the dock's current padding layout. When a
|
||||
portal opens, snapshot the current shift and apply only the subsequent shift
|
||||
delta to its animated `bottom`. Adding the full shift moves the portal twice and
|
||||
can place it over the composer controls.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
|
||||
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
||||
targets per host, batch their reads, and stop until GitHub's reset time when
|
||||
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
||||
path. Resolve fork PRs through their parent repository without abandoning the
|
||||
shared batch.
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
|
||||
+12
-8
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
|
||||
relationship is independent of its current transport, so a future transport can replace the direct
|
||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||
|
||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
|
||||
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
|
||||
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
|
||||
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
|
||||
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
|
||||
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||
manage their own relationship or permissions.
|
||||
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
|
||||
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
|
||||
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
|
||||
shape.
|
||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||
connection receives its persisted permissions. Legacy relationship files carrying
|
||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
|
||||
@@ -259,7 +259,9 @@ background music stays paused. On iOS this is not just a "while recording" probl
|
||||
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
|
||||
it every time the app returns to the foreground — so one dictation turn kills music for the life of
|
||||
the process, on every open, until the app is force-quit. Android holds
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
|
||||
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
|
||||
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
|
||||
|
||||
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
|
||||
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
|
||||
@@ -267,9 +269,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
|
||||
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
|
||||
wrappers (voice provider + dictation) and only it knows the true state.
|
||||
|
||||
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
|
||||
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
|
||||
playing.
|
||||
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
|
||||
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
|
||||
background/foreground the app and confirm it keeps playing at full quality.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Daemon permissions
|
||||
|
||||
The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority.
|
||||
|
||||
## Model
|
||||
|
||||
```text
|
||||
principal -> grants
|
||||
|
|
||||
+-- authenticated by a device or service credential
|
||||
`-- opens a session with equal or narrower authority
|
||||
```
|
||||
|
||||
A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts.
|
||||
|
||||
A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Authority |
|
||||
| ------------------- | -------------------------------------------------------------------------- |
|
||||
| `daemon.read` | Daemon status, diagnostics, configuration, and provider information |
|
||||
| `daemon.manage` | Restart, update, configuration changes, providers, skills, and plugins |
|
||||
| `tunnel.manage` | Relay, Hub, service tunnel, and public endpoint relationships |
|
||||
| `access.manage` | Pairing invitations, principals, credentials, grants, and revocation |
|
||||
| `workspace.read` | Projects, workspaces, agents, timelines, files, diffs, and terminal output |
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | The Hub-owned execution lifecycle |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
## Resources
|
||||
|
||||
Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module:
|
||||
|
||||
```ts
|
||||
type Grant = {
|
||||
permission: Permission;
|
||||
resource: { kind: "daemon" } | { kind: "workspace"; ids: string[] };
|
||||
};
|
||||
```
|
||||
|
||||
A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them.
|
||||
|
||||
Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships.
|
||||
|
||||
## Hub
|
||||
|
||||
The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access.
|
||||
|
||||
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
||||
|
||||
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
|
||||
+115
-19
@@ -1,9 +1,9 @@
|
||||
# Local plugins
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
this first slice does not sandbox it.
|
||||
Paseo does not sandbox it.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -74,8 +74,33 @@ backend code can access the daemon machine, while client contributions run insid
|
||||
|
||||
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.
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
directory always wins over shorthand resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo --path plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||
it before changing the configured path. A compilation or startup failure restores the running
|
||||
version. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
Git installation does not run a package manager or install script. A distributable source plugin
|
||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||
dependencies work only when the Paseo app already ships the native module.
|
||||
|
||||
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
|
||||
@@ -96,15 +121,20 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks 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`.
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | hooks and UI types |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
@@ -139,8 +169,8 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess.
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents,
|
||||
providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
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.
|
||||
@@ -165,11 +195,76 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
|
||||
app state or a memoized selection. Panels use one persisted
|
||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
|
||||
when moved between hosts. Explorer configuration can create workspace-context panels and remove
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||
workspace-layout store access.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
|
||||
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||
client state without mounting a panel or surface.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
built-in projection stay unchanged. The app transforms fetched projected history before building
|
||||
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
|
||||
collapsed before the transformer replaces anything.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
|
||||
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
|
||||
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
|
||||
schema, and mounts the component inside the normal plugin runtime and error boundary.
|
||||
|
||||
Transformers run synchronously and must be deterministic. When several transformers match, the
|
||||
first one that returns a result owns that source item. Plugin and registration ordering is stable.
|
||||
See `plugin-examples/timeline-items` for the complete contract.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
@@ -218,4 +313,5 @@ against the installed catalog on every change; an id nothing contributes falls b
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||
`plugin-examples/catppuccin` for a theme.
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Freedom and flexibility. Every design decision follows from this:
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
+5
-1
@@ -67,6 +67,8 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
@@ -75,7 +77,9 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# Side panel
|
||||
|
||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
||||
anything an agent asks to show alongside its output.
|
||||
|
||||
Everything that opens, reveals, hides, or asks about it goes through
|
||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
||||
surface the current layout uses.
|
||||
|
||||
## Three renderings, two facts
|
||||
|
||||
| Layout | Rendering |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| compact | slide-over overlay, owned by the panel store |
|
||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
||||
|
||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
||||
side panel pane there disappears with no way back.
|
||||
|
||||
## Placement
|
||||
|
||||
Where a tab lands is an intent the caller declares, not something the store infers
|
||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
||||
difference between them only shows up when the tab is already open somewhere:
|
||||
|
||||
| Mode | New tab | Tab already open |
|
||||
| --------- | ----------------- | ---------------------------- |
|
||||
| `pane` | opens in the pane | **moves** to the pane |
|
||||
| `prefer` | opens in the pane | stays where the user left it |
|
||||
| `focused` | focused pane | focuses it in place |
|
||||
| `ambient` | focused pane | focuses it in place |
|
||||
|
||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
||||
The user is placing the tab and expects it to arrive.
|
||||
|
||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
||||
deliberately moved it to is the failure this mode exists to prevent.
|
||||
|
||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
||||
is, because the user is standing in it.
|
||||
|
||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
||||
every caller. Do not re-place a tab after opening it.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
||||
it empties or the user dismisses it:
|
||||
|
||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
||||
- **Close pane** on any other pane **removes** it.
|
||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
||||
visible.
|
||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
||||
|
||||
`closePane` in the layout store owns that distinction. React renders the button and
|
||||
does not decide what it means.
|
||||
|
||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
||||
side panel, which the user summons and expects to find again, split size and all.
|
||||
|
||||
## What the side panel never does
|
||||
|
||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
||||
Running and successful setup never seed tabs.
|
||||
|
||||
## Routing preference
|
||||
|
||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
||||
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
||||
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
||||
hides the panel without closing the tab. A different visible Side panel tab switches to
|
||||
Changes instead.
|
||||
|
||||
## The name
|
||||
|
||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
||||
either would fail every saved blob and wipe the layout it describes.
|
||||
@@ -186,9 +186,13 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
||||
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||
|
||||
## Pull-request test routing
|
||||
|
||||
|
||||
+12
-10
@@ -90,26 +90,28 @@ history start; a response requested from a pre-replacement range is stale and is
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The host runtime owns each session replica for as long as the host remains registered. React
|
||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
|
||||
behind one interface. React supplies transport and projection operations without selecting a cache
|
||||
path or issuing a separate persistence notification.
|
||||
|
||||
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 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.
|
||||
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
|
||||
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
|
||||
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
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.
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
|
||||
sha256-HFYBrCP62r6ZofazBIGSOnMhk8IIEmv+TFJMpqTIJQ8=
|
||||
|
||||
Generated
+158
-156
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
@@ -123,6 +123,134 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.246.tgz",
|
||||
"integrity": "sha512-FtR0HoHHNqeqJWjZN8qLUAzZVFUI9ztXYNPPwv98Ecmv9qq2QTauI8IzkY26CC0mleWAqb9RQEW2C0OtiUliug==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.246",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.246"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.246.tgz",
|
||||
"integrity": "sha512-pYDv2RT+UVOvEapMEWKf7Gm5a87Si1Zf+XR23IMGYbG5mESatNJ8Gz2PmkrwfTezZknb0ljWZe8zgcTFITM3dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.246.tgz",
|
||||
"integrity": "sha512-yl+gMMFUQ8++SBaxUe2JxBqU0LOJO4HZTsWWTOrH5kRlTRhdQDF/lESsoNXCHgfF/+77YNKgef0h+rl/D12Jfg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.246.tgz",
|
||||
"integrity": "sha512-t7AEewD7bcjZYMmrfFLjsUSxPo4z+5J5O5cC9V2zOvAhNkefdMeGWI2/1cdqZxuaOiFVt46byjlZuiNNunF+bQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.246.tgz",
|
||||
"integrity": "sha512-AeiDXveuA9dVRDouSRiQzL40P3dOuJPfZOlwwsFNbVUN0D+06WtwgmeWYnyRG4dxsM67Pf4lYcRZ0x9pfEZUwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.246.tgz",
|
||||
"integrity": "sha512-BIbBlJ6x7Cys6UFvahUfce26H+U+Q4tLWjKRD39AaV7JvRav8/vfdFytzWq6E+h9hT1OOWYuPYx2gMn5vDhZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.246.tgz",
|
||||
"integrity": "sha512-+3PrWX3qXgakO3tZImr7keKFry5XIkbHBEaUxgi3/RtPmxGwS+JdVAr0xxzOoC2F6Y66Kn0lUnztRnZHORBc+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.246.tgz",
|
||||
"integrity": "sha512-JV8b28OJ8d+EMVEVIWlN3aEkT8uSehO3ZN/PJzXFG7Jgn56oVYUew4aY9vK4jao/4E4mdfzvs358w4HcTBUzVw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.246",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.246.tgz",
|
||||
"integrity": "sha512-omI65bYGynLpE1Ybijk56K1wBnt0H6WujNwlg1LzIdeHrYmpUxoqncK92xKH5kb9XWUz3byHqArbv7wsT+M/2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.104.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
|
||||
@@ -36170,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37235,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/server": "0.5.1",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/server": "0.7.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37487,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37501,8 +37629,8 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.5.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/server": "*",
|
||||
@@ -37744,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38640,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38669,12 +38797,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38683,14 +38812,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38703,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38921,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/highlight": "0.5.1",
|
||||
"@getpaseo/plugin": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@getpaseo/client": "0.7.1",
|
||||
"@getpaseo/highlight": "0.7.1",
|
||||
"@getpaseo/plugin": "0.7.1",
|
||||
"@getpaseo/protocol": "0.7.1",
|
||||
"@getpaseo/relay": "0.7.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38976,134 +39106,6 @@
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"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",
|
||||
@@ -39983,7 +39985,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -11,7 +11,7 @@
|
||||
"voice-assistant"
|
||||
],
|
||||
"homepage": "https://paseo.sh",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
@@ -41,6 +41,7 @@
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
|
||||
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
|
||||
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("method selection has no search field", async ({ page }) => {
|
||||
test("method selection shows the daemon's available project sources without search", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill("/tmp");
|
||||
|
||||
const backGlyph = addProjectFlowBack(page).locator("svg");
|
||||
const resultGlyph = addProjectFlow(page)
|
||||
.locator('[data-testid^="add-project-flow-path-"]')
|
||||
.first()
|
||||
.locator("svg");
|
||||
await expect(resultGlyph).toBeVisible();
|
||||
|
||||
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
|
||||
backGlyph.boundingBox(),
|
||||
addProjectFlowInput(page).boundingBox(),
|
||||
resultGlyph.boundingBox(),
|
||||
page.getByTestId("add-project-flow-title").boundingBox(),
|
||||
page.getByTestId("add-project-flow-results").boundingBox(),
|
||||
page.getByTestId("add-project-flow-footer").boundingBox(),
|
||||
]);
|
||||
expect(backBox).not.toBeNull();
|
||||
expect(inputBox).not.toBeNull();
|
||||
expect(resultBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(resultsBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
|
||||
|
||||
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(titleBox.height).toBeLessThanOrEqual(24);
|
||||
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
||||
});
|
||||
|
||||
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
|
||||
const viewportTop = frame?.userRow.rect?.top;
|
||||
const scrollTop = frame?.scroll.scrollTop;
|
||||
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
|
||||
}
|
||||
|
||||
function expectRowContinuity(frames: TurnFrame[]): void {
|
||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||
const baseline = frames[first]?.userRow.rect?.top;
|
||||
const baselineFrame = frames[first];
|
||||
const baselineContentTop = submittedRowContentTop(baselineFrame);
|
||||
const disappeared = frames.findIndex(
|
||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const reappeared = frames.findIndex(
|
||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const shifted = frames.findIndex(
|
||||
(frame, index) =>
|
||||
const contentShifted = frames.findIndex((frame, index) => {
|
||||
const contentTop = submittedRowContentTop(frame);
|
||||
return (
|
||||
index > first &&
|
||||
hasPaintedLayout(frame.userRow) &&
|
||||
baseline !== undefined &&
|
||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
||||
baselineContentTop !== undefined &&
|
||||
contentTop !== undefined &&
|
||||
Math.abs(contentTop - baselineContentTop) > 1
|
||||
);
|
||||
});
|
||||
const viewportChanged = frames.findIndex(
|
||||
(frame, index) =>
|
||||
index > first &&
|
||||
[
|
||||
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
|
||||
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
|
||||
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
|
||||
].some(Boolean),
|
||||
);
|
||||
const attachmentMissing = frames.findIndex(
|
||||
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
||||
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
|
||||
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
||||
]
|
||||
: []),
|
||||
...(shifted >= 0
|
||||
...(contentShifted >= 0
|
||||
? [
|
||||
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
|
||||
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
|
||||
]
|
||||
: []),
|
||||
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
|
||||
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
||||
];
|
||||
let failure = Math.max(0, first);
|
||||
if (attachmentMissing >= 0) failure = attachmentMissing;
|
||||
if (shifted >= 0) failure = shifted;
|
||||
if (viewportChanged >= 0) failure = viewportChanged;
|
||||
if (contentShifted >= 0) failure = contentShifted;
|
||||
if (disappeared >= 0) failure = disappeared;
|
||||
expect(
|
||||
violations,
|
||||
|
||||
@@ -23,7 +23,7 @@ async function completeSubmittedTurn(
|
||||
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async function expectTurnCompletesNormally(
|
||||
): Promise<void> {
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderFeaturePreferences(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]?.featureValues ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await expect
|
||||
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
|
||||
.toEqual({ fast_mode: true });
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
|
||||
@@ -45,6 +45,49 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("opens a timeline image in a zoomable lightbox", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Zoomable timeline image",
|
||||
fileName: "zoomable-timeline-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Zoomable image timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.getByRole("button", { name: "Open image attachment" }).click();
|
||||
const lightboxImage = page.getByTestId("attachment-lightbox-image");
|
||||
const canvas = page.getByTestId("attachment-lightbox-canvas");
|
||||
await expect(lightboxImage).toBeVisible();
|
||||
await canvas.hover();
|
||||
const transformedContent = canvas.locator(":scope > div").first();
|
||||
const initialBox = await transformedContent.boundingBox();
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
expect(initialBox).not.toBeNull();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
expect(initialBox!.width).toBeLessThan(canvasBox!.width);
|
||||
const zoomIn = page.getByRole("button", { name: "Zoom in", exact: true });
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(canvasBox!.width);
|
||||
|
||||
await canvas.dblclick({ position: { x: canvasBox!.width / 2, y: canvasBox!.height / 2 } });
|
||||
await expect
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await canvas.click({ position: { x: 4, y: 4 } });
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -18,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected workspace visible in Light", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "light-selected-workspace-",
|
||||
title: "Selected workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "light" }));
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pure-black-selected-workspace-",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
@@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => {
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
|
||||
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
repoPrefix: "bottom-sheet-reopen-",
|
||||
title: "Bottom sheet reopen e2e",
|
||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("mobile bottom sheet reopen", () => {
|
||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("search selection returns to configuration options", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await page.getByTestId("model-search-all-input").click();
|
||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||
await expect(model).toBeVisible({
|
||||
timeout: 10_000,
|
||||
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
try {
|
||||
await test.step("tab switcher opens, closes, and reopens", async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
});
|
||||
|
||||
await model.click();
|
||||
await test.step("model selector opens, closes, and reopens", async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
});
|
||||
await test.step("model search returns to configuration", async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
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 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();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
|
||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "branch-switch-",
|
||||
repo: { branches: ["main", "dev"] },
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("dev");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -19,6 +19,10 @@ interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
includeNestedFolders?: boolean;
|
||||
includeRenamedFile?: boolean;
|
||||
// A root-level file whose name sorts BEFORE the root-level "src" directory.
|
||||
// zz-untracked.txt cannot stand in for it: "src" < "zz", so that file lands
|
||||
// last whether paths are compared whole or segment by segment.
|
||||
includeRootFileSortingFirst?: boolean;
|
||||
includeUntrackedFile?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,11 +34,7 @@ const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-tree");
|
||||
}
|
||||
|
||||
function changesContent(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-content");
|
||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||
@@ -264,7 +264,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -272,19 +273,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -297,7 +298,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
@@ -305,8 +307,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
@@ -321,7 +323,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
@@ -432,7 +434,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
@@ -447,8 +448,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
@@ -466,12 +465,36 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the scrolling diff lists files in changes tree order", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeNestedFolders: true,
|
||||
includeRootFileSortingFirst: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffNames).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
await expect(diffNames).toHaveText(expected);
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
@@ -516,7 +539,6 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
@@ -649,6 +671,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
@@ -658,34 +685,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
||||
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await diffOptions.click();
|
||||
// A workspace tab has nowhere to open itself, so the menu withholds that item.
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
||||
// Leading icon only: the layout is still unified, so no trailing check.
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
||||
await layoutItem.click();
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
@@ -701,151 +724,23 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectFlatFileList(page);
|
||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
||||
await Promise.all([
|
||||
page.getByTestId("changes-header").boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
||||
throw new Error("Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(changesToolbarBox.height).toBe(36);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(optionsBox.width).toBe(24);
|
||||
expect(optionsBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(optionsGlyphBox.width).toBe(14);
|
||||
expect(optionsGlyphBox.height).toBe(14);
|
||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await diffOptions.hover();
|
||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
||||
await page.getByTestId("changes-toggle-tree").hover();
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByTestId("changes-toggle-whitespace").click();
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await tree
|
||||
.getByTestId("diff-folder-src-toggle")
|
||||
.locator("svg")
|
||||
.boundingBox();
|
||||
expect(folderToggleBounds).not.toBeNull();
|
||||
expect(folderChevronBounds).not.toBeNull();
|
||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await tree
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await tree
|
||||
.getByTestId("diff-tree-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(12, 0);
|
||||
|
||||
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await fileToggle.click();
|
||||
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const contentBounds = await content.boundingBox();
|
||||
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
expect(focusedFileBounds).not.toBeNull();
|
||||
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
|
||||
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
||||
await expectFlatFileList(page);
|
||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
if (!(await compactChangesTab.isVisible())) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
}
|
||||
await expect(compactChangesTab).toBeVisible();
|
||||
await compactChangesTab.click();
|
||||
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||
|
||||
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||
const options = compactExplorer.getByRole("button", { name: "Diff options" });
|
||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||
actions.boundingBox(),
|
||||
options.boundingBox(),
|
||||
@@ -873,7 +768,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await expect(wrapLines).toBeVisible();
|
||||
await wrapLines.click();
|
||||
await options.click();
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
||||
await expect(
|
||||
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
@@ -883,7 +780,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
|
||||
const handle = page
|
||||
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||
.getByRole("separator");
|
||||
await expect(handle).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -904,11 +803,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
};
|
||||
}),
|
||||
]);
|
||||
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
|
||||
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
|
||||
|
||||
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
|
||||
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
|
||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
const duringDrag = await Promise.all([
|
||||
@@ -1074,6 +973,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(grab).toHaveCSS("cursor", "grabbing");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -1313,24 +1213,20 @@ async function setOpenChangesPresentation(
|
||||
page: Page,
|
||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||
): Promise<void> {
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
|
||||
await options.click();
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
|
||||
const currentLayout =
|
||||
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
|
||||
? "split"
|
||||
: "unified";
|
||||
if (currentLayout !== requestedPresentation.layout) {
|
||||
await layoutItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await options.click();
|
||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
||||
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
|
||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||
await wrapItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,12 +1261,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFlatFileList(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
await expect
|
||||
@@ -1424,6 +1314,9 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeUntrackedFile) {
|
||||
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||
}
|
||||
if (options.includeRootFileSortingFirst) {
|
||||
await writeFile(path.join(repo.path, "a-root-note.txt"), "root note\n");
|
||||
}
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
@@ -1491,6 +1384,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
@@ -1507,13 +1401,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||
await changesTab.click();
|
||||
const changedFile = explorer
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await changedFile.click();
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||
|
||||
function mainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page) {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
|
||||
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
@@ -24,34 +15,12 @@ test("Changes shortcut follows the default Side panel placement", async ({ page
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
clickChatOutlineRowEdge,
|
||||
disableChatOutlineFromAppearance,
|
||||
expectActiveChatOutlinePrompt,
|
||||
expectActiveChatOutlinePromptMovedFrom,
|
||||
expectChatOutlinePreview,
|
||||
expectChatOutlinePrompts,
|
||||
expectChatOutlinePromptToRemainBare,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
openAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineToOldestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
type LongTimelineAgent,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
} from "../support/helpers/command-center-agent-controls";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
// model. Asserting both model and mode catches either row winning.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-relevance-",
|
||||
title: "Command Center relevance",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("matches query tokens in any order", async ({ page }) => {
|
||||
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||
// a substring of it. Only per-token matching finds this row.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-tokenized-",
|
||||
title: "Command Center tokenized search",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-action-priority-",
|
||||
title: "Command Center action priority",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-active-reset-",
|
||||
title: "Command Center active reset",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -1,53 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
|
||||
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
|
||||
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string): Locator {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
function action(panel: Locator, title: string): Locator {
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = workspaceRow(page, workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
|
||||
// that used to make the sidebar-owned rename dialog unreachable.
|
||||
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: project.projectDisplayName });
|
||||
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
|
||||
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
|
||||
// surface under test anyway, and it works from every route.
|
||||
async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, query: string, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
const entry = action(panel, title);
|
||||
await expect(entry).toBeVisible({ timeout: 15_000 });
|
||||
await entry.click();
|
||||
}
|
||||
|
||||
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
|
||||
const workspaces = await workspace.client.fetchWorkspaces();
|
||||
for (const entry of workspaces.entries) {
|
||||
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
let rejectNext = true;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
let request: { type?: string; requestId?: string } | null = null;
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
message?: { type?: string; requestId?: string };
|
||||
};
|
||||
request = envelope.type === "session" ? (envelope.message ?? null) : null;
|
||||
} catch {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
rejectNext &&
|
||||
request?.type === "workspace.label.assignment.set.request" &&
|
||||
request.requestId
|
||||
) {
|
||||
rejectNext = false;
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestType: request.type,
|
||||
error: "Injected label mutation failure.",
|
||||
code: "handler_error",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
try {
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
// Unassigning the only label drops the field entirely rather than leaving an empty array.
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
|
||||
|
||||
const reopened = await openCommandCenter(page);
|
||||
await reopened.getByTestId("command-center-input").fill("urgent");
|
||||
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an error toast when assigning a label fails", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
|
||||
|
||||
try {
|
||||
await rejectNextLabelAssignment(page);
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(
|
||||
"Injected label mutation failure.",
|
||||
);
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
@@ -15,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 openChangesPanel(page);
|
||||
await openChangesTreePanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -35,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 openChangesPanel(page);
|
||||
await openChangesTreePanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||
}
|
||||
expect(commitToolbarBox.height).toBe(36);
|
||||
expect(layoutToggleBox.width).toBe(24);
|
||||
expect(layoutToggleBox.height).toBe(24);
|
||||
expect(layoutToggleBox.width).toBe(20);
|
||||
expect(layoutToggleBox.height).toBe(20);
|
||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -10,6 +10,22 @@ function visibleMainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function composerChangesPill(page: Page) {
|
||||
return page.getByTestId("composer-diff-stat-pill");
|
||||
}
|
||||
|
||||
async function revealComposerChangesInExplorer(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openComposerDiff(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
@@ -38,7 +54,12 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||
test("composer diff stat reveals Changes, then opens the diff in the configured side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { diffs: true } }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
@@ -48,37 +69,31 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||
const pill = composerChangesPill(page);
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("+2");
|
||||
await expect(pill).toContainText("-0");
|
||||
await pill.click();
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
|
||||
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
const sidePane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ visible: true })
|
||||
.filter({ has: page.getByTestId("working-diff-panel") });
|
||||
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
|
||||
|
||||
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||
await test.step("Explorer navigation does not replace the side pane", async () => {
|
||||
await openFilesPanel(page);
|
||||
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
|
||||
|
||||
await pill.click();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel).toHaveCount(0);
|
||||
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("clicking again restores Changes", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -94,21 +109,26 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const closeExplorer = page
|
||||
.getByTestId("explorer-header")
|
||||
.getByRole("button", { name: "Close Explorer sidebar" });
|
||||
await expect(closeExplorer).not.toBeInViewport();
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(closeExplorer).toBeInViewport({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
||||
test("composer diff stat reveals Changes, then opens the diff in the focused pane by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||
|
||||
try {
|
||||
@@ -118,14 +138,17 @@ test("composer diff stat opens Changes in the focused pane when Side panel routi
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
|
||||
const mainPane = visibleMainPane(page);
|
||||
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
repoPrefix: "composer-bottom-anchor-",
|
||||
title: "Composer bottom anchor",
|
||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
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 runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const focusedPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const originalPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||
await expect(focusedPaneChild).toBeVisible();
|
||||
const focusedPaneId = await focusedPaneChild
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getAttribute("data-testid");
|
||||
const originalPaneId = await originalPaneChild
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getAttribute("data-testid");
|
||||
expect(focusedPaneId).not.toBeNull();
|
||||
expect(originalPaneId).not.toBeNull();
|
||||
const focusedPane = page.getByTestId(focusedPaneId!);
|
||||
const originalPane = page.getByTestId(originalPaneId!);
|
||||
|
||||
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);
|
||||
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -13,38 +13,25 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
|
||||
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||
}
|
||||
|
||||
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+E`);
|
||||
const launcher = page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-new-tab-panel");
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
return launcher;
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
test.describe("New tab keyboard launcher", () => {
|
||||
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const launcher = await openSidePanelWithKeyboard(page);
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-tab-files"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = await openExplorerWithKeyboard(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -65,14 +52,12 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Terminal");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await expectLauncherSelection(launcher, "Diff");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
@@ -22,23 +25,18 @@ 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. */
|
||||
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
has: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
return visible(page, "workspace-tabs-row");
|
||||
}
|
||||
|
||||
async function focusExplorerPane(page: Page): Promise<void> {
|
||||
await openChangesPanel(page);
|
||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||
@@ -115,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("focus the explorer pane", async () => {
|
||||
await focusExplorerPane(page);
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
});
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
@@ -140,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
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",
|
||||
);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
@@ -170,8 +165,9 @@ test.describe("explorer pane tab placement", () => {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
ensureExplorerSidebar,
|
||||
openFilesPanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
test.describe("Explorer sidebar", () => {
|
||||
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const mainTabsBefore = await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.locator('[data-testid^="workspace-tab-"]')
|
||||
.count();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||
|
||||
await openFilesPanel(page);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
|
||||
).toHaveCount(mainTabsBefore);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,14 +19,8 @@ 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 desktop companion surface is a hideable side panel. */
|
||||
function sidePanel(page: Page): Locator {
|
||||
return visible(page, "workspace-side-panel");
|
||||
function explorerSidebar(page: Page): Locator {
|
||||
return visible(page, "workspace-explorer-sidebar");
|
||||
}
|
||||
|
||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
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);
|
||||
|
||||
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step("the retired keys are migrated out of storage", async () => {
|
||||
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("the shortcut collapses both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(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(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
@@ -218,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
||||
);
|
||||
}, APP_SETTINGS_KEY);
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink({
|
||||
target,
|
||||
@@ -530,6 +538,16 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
const fittedImageBox = await transformedContent.boundingBox();
|
||||
expect(fittedImageBox).not.toBeNull();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(fittedImageBox!.width * 1.2);
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
||||
// was permanent on desktop.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
|
||||
return locator.evaluate((element) => {
|
||||
let current: Element | null = element;
|
||||
while (current) {
|
||||
const color = getComputedStyle(current).backgroundColor;
|
||||
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return "transparent";
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-tree-rail-toggle-",
|
||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File panel tree rail", () => {
|
||||
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
const content = rail.getByTestId("files-tree-rail-content");
|
||||
const tree = rail.getByTestId("files-tree-rail-tree");
|
||||
await expect(content).toHaveText("Choose a file");
|
||||
await expect(tree).toContainText("docs");
|
||||
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
|
||||
await effectiveBackground(content),
|
||||
);
|
||||
});
|
||||
|
||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
|
||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
||||
const hiddenFiles = page
|
||||
.getByRole("button", { name: "Hide hidden files" })
|
||||
.filter({ visible: true });
|
||||
const shoot = async (name: string) => {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
};
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
const [
|
||||
editorToolbarBox,
|
||||
filesToolbarBox,
|
||||
treeToggleBox,
|
||||
refreshBox,
|
||||
treeGlyphBox,
|
||||
refreshGlyphBox,
|
||||
] = await Promise.all([
|
||||
editorToolbar.boundingBox(),
|
||||
filesToolbar.boundingBox(),
|
||||
toggle.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
toggle.locator("svg").boundingBox(),
|
||||
refresh.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!editorToolbarBox ||
|
||||
!filesToolbarBox ||
|
||||
!treeToggleBox ||
|
||||
!refreshBox ||
|
||||
!treeGlyphBox ||
|
||||
!refreshGlyphBox
|
||||
) {
|
||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
||||
}
|
||||
expect(editorToolbarBox.height).toBe(36);
|
||||
expect(filesToolbarBox.height).toBe(36);
|
||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
||||
filesToolbarBox.y + filesToolbarBox.height,
|
||||
);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(refreshBox.width).toBe(24);
|
||||
expect(refreshBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(refreshGlyphBox.width).toBe(14);
|
||||
expect(refreshGlyphBox.height).toBe(14);
|
||||
|
||||
const restBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await refresh.hover();
|
||||
const refreshHoverBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
||||
await hiddenFiles.click();
|
||||
const hiddenFilesSelected = page
|
||||
.getByRole("button", { name: "Show hidden files" })
|
||||
.filter({ visible: true });
|
||||
expect(
|
||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(refreshHoverBackground);
|
||||
await shoot("tree-open");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(rail).toHaveCount(0);
|
||||
await shoot("tree-closed");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(rail).toBeVisible();
|
||||
await shoot("tree-reopened");
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectExplorerNodeRetained,
|
||||
hideExplorerSplit,
|
||||
measureExplorerSplit,
|
||||
openExplorerSplit,
|
||||
resizeExplorerSplit,
|
||||
stampExplorerNode,
|
||||
} from "../support/helpers/split-pane";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Hidden split pane retention", () => {
|
||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await createAgentTabFromMenu(page);
|
||||
await openExplorerSplit(page);
|
||||
|
||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
||||
await resizeExplorerSplit(page, 137);
|
||||
await stampExplorerNode(page);
|
||||
const open = await measureExplorerSplit(page);
|
||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
||||
expect(open.separatorCount).toBe(1);
|
||||
|
||||
await hideExplorerSplit(page);
|
||||
const hidden = await measureExplorerSplit(page);
|
||||
expect(hidden.explorerWidth).toBe(0);
|
||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.separatorCount).toBe(0);
|
||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
||||
await expectExplorerNodeRetained(page);
|
||||
|
||||
await openExplorerSplit(page);
|
||||
await expectExplorerNodeRetained(page);
|
||||
const reopened = await measureExplorerSplit(page);
|
||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
||||
expect(reopened.separatorCount).toBe(1);
|
||||
|
||||
await testInfo.attach("split-pane-geometry", {
|
||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
countTabsOfKind,
|
||||
waitForTabBar,
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
@@ -177,13 +178,11 @@ test.describe("Tab creation", () => {
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||
const changes = panel.getByRole("button", { name: /Changes/ });
|
||||
const files = panel.getByRole("button", { name: /Files/ });
|
||||
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||
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}.*G`));
|
||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
@@ -191,6 +190,12 @@ test.describe("Tab creation", () => {
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
await pressDirectNewTabShortcut(page, "e");
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||
await panel.click({ position: { x: 20, y: 20 } });
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
@@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => {
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
|
||||
const GREETING = "Say hello back.";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
|
||||
function verificationPrompt(workspacePath: string): string {
|
||||
return [
|
||||
"Use Paseo tools to perform this exact UI verification.",
|
||||
"Do not edit files or run shell commands. Every prompt below is intentionally non-actionable.",
|
||||
`1. Create a local workspace at ${workspacePath} titled "Presentation QA workspace".`,
|
||||
`2. In that workspace, create a codex/gpt-5.4-mini agent titled "Greeting child" with the initial prompt "${GREETING}".`,
|
||||
`3. Create a schedule named "Greeting schedule" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", provider "codex/gpt-5.4-mini", cwd ${workspacePath}, maxRuns 1, and expiresIn "1h". Delete that schedule using its returned id.`,
|
||||
`4. Create a heartbeat named "Greeting heartbeat" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", maxRuns 1, and expiresIn "1h". Delete that heartbeat using its returned id.`,
|
||||
`5. After the child agent finishes, send it the follow-up prompt "${GREETING}" synchronously.`,
|
||||
"6. Archive the workspace from step 1.",
|
||||
"7. Reply with exactly TOOL_CALL_QA_DONE.",
|
||||
"Use each named Paseo tool exactly once except for the matching schedule and heartbeat deletion tools.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function expandedToolCall(page: Page, label: string): Promise<Locator> {
|
||||
const badge = page.getByTestId("tool-call-badge").filter({ hasText: label }).last();
|
||||
await expect(badge).toBeVisible({ timeout: 30_000 });
|
||||
await badge.scrollIntoViewIfNeeded();
|
||||
await badge.click();
|
||||
return badge;
|
||||
}
|
||||
|
||||
async function captureToolCall(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
input: { label: string; artifact: string; expectedText: Array<string | RegExp> },
|
||||
): Promise<void> {
|
||||
const badge = await expandedToolCall(page, input.label);
|
||||
for (const text of input.expectedText) {
|
||||
await expect(badge).toContainText(text);
|
||||
}
|
||||
const screenshot = testInfo.outputPath(`${input.artifact}.png`);
|
||||
await badge.screenshot({ path: screenshot });
|
||||
await testInfo.attach(input.artifact, { path: screenshot, contentType: "image/png" });
|
||||
await badge.click();
|
||||
}
|
||||
|
||||
async function runPresentationJourney(handle: AgentHandle, workspacePath: string): Promise<void> {
|
||||
await sendMessage(handle, verificationPrompt(workspacePath));
|
||||
await expect(
|
||||
handle.page.getByTestId("assistant-message").filter({ hasText: "TOOL_CALL_QA_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("renders owned tool inputs and results as readable text", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-tool-call-presentation-")));
|
||||
const childWorkspacePath = path.join(cwd, "presentation-workspace");
|
||||
mkdirSync(childWorkspacePath);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
|
||||
await runPresentationJourney(handle, childWorkspacePath);
|
||||
await page.setViewportSize({ width: 1280, height: 1600 });
|
||||
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create workspace",
|
||||
artifact: "create-workspace-presentation",
|
||||
expectedText: ["Details", "Presentation QA workspace", childWorkspacePath, "local"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create agent",
|
||||
artifact: "create-agent-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create schedule",
|
||||
artifact: "schedule-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting schedule",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create heartbeat",
|
||||
artifact: "heartbeat-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting heartbeat",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Archive workspace",
|
||||
artifact: "archive-workspace-presentation",
|
||||
expectedText: ["Details", "Workspace", "Result", "Removed directory"],
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||
// No unit test can catch that, because the defect lives in the JSX.
|
||||
//
|
||||
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||
// behavior is the same for every agent.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plan-card-markdown-",
|
||||
title: "Plan card markdown e2e",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
await expect(planCard).not.toContainText("©");
|
||||
await expect(planCard).not.toContainText("—buzz");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function ModalBody({ onSaved }) {
|
||||
usePaseo();
|
||||
useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
function save() {
|
||||
toast.show("Issue saved", { variant: "success" });
|
||||
onSaved();
|
||||
}
|
||||
|
||||
return <View>
|
||||
<Text>Plugin modal contexts ready</Text>
|
||||
<Pressable accessibilityRole="button" onPress={save}>
|
||||
<Text>Save issue</Text>
|
||||
</Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Surface() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <View>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Icon name="Pencil" size={18} />
|
||||
<Text>Open plugin modal</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Modal
|
||||
title="Edit plugin issue"
|
||||
icon={<Icon name="Pencil" size={18} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Modal.Content>
|
||||
<ModalBody onSaved={() => setOpen(false)} />
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Host UI",
|
||||
icon: "PanelsTopLeft",
|
||||
surface: "main",
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function openHostUiPlugin(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function useNonCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
}
|
||||
|
||||
async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactPluginSheet(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function savePluginIssue(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save issue", exact: true }).click();
|
||||
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await useNonCompactLayout(page);
|
||||
await openHostUiPlugin(page);
|
||||
|
||||
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||
await openPluginModal(page);
|
||||
await expectCenteredPluginDialog(page);
|
||||
await closeCenteredPluginDialog(page);
|
||||
});
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -28,10 +29,18 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginSource(): string {
|
||||
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const recordComposerOpen = defineRpc({
|
||||
name: "composer.open",
|
||||
input: z.object({ workspaceId: z.string() }),
|
||||
output: z.object({ opened: z.boolean() }),
|
||||
});
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
@@ -46,15 +55,70 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
|
||||
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
|
||||
}
|
||||
|
||||
function DirectCollisionSurface() {
|
||||
return <View><Text>Direct collision surface</Text></View>;
|
||||
function DirectCollisionSurface({ navigation }) {
|
||||
return <View>
|
||||
<Text>Direct collision surface</Text>
|
||||
{navigation ? <>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
|
||||
</> : null}
|
||||
</View>;
|
||||
}
|
||||
|
||||
function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
pills.get(agentId)?.();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind === "remove") {
|
||||
remove(update.agentId);
|
||||
return;
|
||||
}
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
removePill();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, removePill);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const removePill of pills.values()) removePill();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||
return { opened: true };
|
||||
});
|
||||
plugin.addSurface("collision", DirectCollisionSurface);
|
||||
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||
@@ -64,6 +128,7 @@ export default function contribute(plugin) {
|
||||
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
@@ -119,7 +184,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource());
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||
);
|
||||
|
||||
try {
|
||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -180,6 +248,38 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
});
|
||||
|
||||
await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(page.getByTestId("workspace-header-title")).toBeVisible();
|
||||
|
||||
const agent = await primary.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: primary.repoPath,
|
||||
workspaceId: primary.workspaceId,
|
||||
title: "Plugin navigation agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const navigationAgentId = agent.id;
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||
);
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${navigationAgentId}`)
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("switching hosts removes commands from an uninstalled host", async () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -207,7 +307,29 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
});
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-composer-pill-compact");
|
||||
await page.getByRole("button", { name: "Open composer review" }).click();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Opened from composer pill",
|
||||
);
|
||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
await runCommand(page, "Open plugin agent");
|
||||
await closeMobileAgentSidebar(page);
|
||||
@@ -218,7 +340,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
});
|
||||
|
||||
await test.step("removing the active plugin renders the panel unavailable", async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
expectPrPaneChecks,
|
||||
expectPrPaneActivity,
|
||||
} from "../support/helpers/pr-pane";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
await expectPrPaneChecks(page, {
|
||||
success: ["build-1", "build-2"],
|
||||
failure: ["deploy"],
|
||||
pending: ["security"],
|
||||
});
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
test("renders every seeded activity comment", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
expectedName: "Verify OpenCode descriptor",
|
||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||
expectsUserMessage: false,
|
||||
providerConfig: { model: "openai/gpt-5.4" },
|
||||
prompt:
|
||||
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||
|
||||
@@ -26,15 +26,35 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
async function rewriteCachedMessageAsLegacyRow(
|
||||
page: Page,
|
||||
input: { prompt: string; agentId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.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;
|
||||
const hasAgent = host.agents.some(
|
||||
(agent) =>
|
||||
agent.snapshot &&
|
||||
typeof agent.snapshot === "object" &&
|
||||
Reflect.get(agent.snapshot, "id") === input.agentId,
|
||||
);
|
||||
const hasWorkspace = host.workspaces.some(
|
||||
(workspace) => workspace.id === input.workspaceId,
|
||||
);
|
||||
if (!hasAgent || !hasWorkspace) continue;
|
||||
for (const timeline of host.timelines) {
|
||||
for (const item of timeline.items ?? []) {
|
||||
if (
|
||||
timeline.agentId === input.agentId &&
|
||||
item.kind === "user_message" &&
|
||||
item.text === input.prompt &&
|
||||
item.messageId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,8 +65,8 @@ async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Prom
|
||||
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);
|
||||
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||
.find((item) => item.kind === "user_message" && item.text === input.prompt);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
await writeReplicaCache(page, cache);
|
||||
@@ -61,7 +81,7 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
@@ -79,7 +99,7 @@ async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string)
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
||||
@@ -131,14 +151,18 @@ test.describe("Rewind sheet", () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectUserMessageVisible(page, prompt);
|
||||
await gate.drop();
|
||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
||||
await rewriteCachedMessageAsLegacyRow(page, {
|
||||
prompt,
|
||||
agentId: session.agentId,
|
||||
workspaceId: session.workspaceId,
|
||||
});
|
||||
await page.reload();
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
|
||||
const restoredMessage = userMessage(page, prompt);
|
||||
await expect(restoredMessage).toBeVisible();
|
||||
await restoredMessage.hover();
|
||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
@@ -153,6 +177,7 @@ test.describe("Rewind sheet", () => {
|
||||
repoPrefix: "rewind-e2e-",
|
||||
title: "Rewind e2e",
|
||||
initialPrompt: firstPrompt,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -70,73 +70,55 @@ test.describe("History search", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
await test.step("narrows history and restores chronological grouping", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Ranked results are one flat list — a day heading would claim an order
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
await test.step("ranks whole-word matches above substrings", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
await test.step("distinguishes no matches from empty history", async () => {
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A typo has no characters in the text to point at, so the whole word it
|
||||
// resolved to is marked.
|
||||
await search(page, `${NONCE} bulling`);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("finds a session through a typo", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
});
|
||||
|
||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("pressing Escape closes settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await test.step("a dropdown owns Escape", async () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel(/Theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
await test.step("a modal owns Escape", async () => {
|
||||
await openAddHostFlow(page);
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
@@ -10,6 +10,58 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
interface ComposerFrame {
|
||||
at: number;
|
||||
visible: boolean;
|
||||
rootHeight: number;
|
||||
inputHeight: number;
|
||||
inputWidth: number;
|
||||
inputStyleHeight: string;
|
||||
inputStyleMaxHeight: string;
|
||||
}
|
||||
|
||||
async function startComposerFrameCapture(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => {
|
||||
const capture = { frames: [] as ComposerFrame[], stopped: false };
|
||||
Object.assign(window, { __composerFrameCapture: capture });
|
||||
|
||||
const sample = () => {
|
||||
if (capture.stopped) return;
|
||||
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
|
||||
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (root && input) {
|
||||
const style = getComputedStyle(input);
|
||||
capture.frames.push({
|
||||
at: performance.now(),
|
||||
visible: root.offsetParent !== null,
|
||||
rootHeight: root.getBoundingClientRect().height,
|
||||
inputHeight: input.getBoundingClientRect().height,
|
||||
inputWidth: input.clientWidth,
|
||||
inputStyleHeight: style.height,
|
||||
inputStyleMaxHeight: style.maxHeight,
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopComposerFrameCapture(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<ComposerFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const capture = (
|
||||
window as typeof window & {
|
||||
__composerFrameCapture?: { frames: ComposerFrame[]; stopped: boolean };
|
||||
}
|
||||
).__composerFrameCapture;
|
||||
if (!capture) throw new Error("Composer frame capture was not installed");
|
||||
capture.stopped = true;
|
||||
return capture.frames;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSendBehavior(
|
||||
page: import("@playwright/test").Page,
|
||||
expected: "interrupt" | "queue" | "steer",
|
||||
@@ -107,6 +159,65 @@ test.describe("Settings toggle tab regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("returning from settings does not flash the composer at maximum height", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-composer-height-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-composer-height-b-${Date.now()}`,
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill("Keep this short draft in the composer");
|
||||
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
const baselineHeight = (await root.boundingBox())?.height;
|
||||
expect(baselineHeight).toBeDefined();
|
||||
|
||||
await startComposerFrameCapture(page);
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
await expect(input).toHaveValue("Keep this short draft in the composer");
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const frames = await stopComposerFrameCapture(page);
|
||||
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||
const tallestIndex = visibleFrames.reduce(
|
||||
(index, frame, candidateIndex) =>
|
||||
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||
0,
|
||||
);
|
||||
const tallestFrame = visibleFrames[tallestIndex];
|
||||
const tallestFrameIndex = frames.findIndex((frame) => frame.at === tallestFrame.at);
|
||||
expect(
|
||||
tallestFrame.rootHeight,
|
||||
JSON.stringify(
|
||||
{
|
||||
baselineHeight,
|
||||
tallestFrame,
|
||||
nearbyFrames: frames.slice(Math.max(0, tallestFrameIndex - 3), tallestFrameIndex + 4),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
).toBeLessThanOrEqual((baselineHeight ?? 0) + 5);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
function mainPane(page: Page): Locator {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function newTabPanel(scope: Locator): Locator {
|
||||
return scope.getByTestId("workspace-new-tab-panel");
|
||||
}
|
||||
|
||||
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
|
||||
return launcher
|
||||
.getByRole("button")
|
||||
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
|
||||
}
|
||||
|
||||
function sidePanelToggle(page: Page): Locator {
|
||||
return page.getByTestId("workspace-explorer-toggle").first();
|
||||
}
|
||||
|
||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
||||
const paneBox = await mainPane(page).boundingBox();
|
||||
const chipBox = await chip.boundingBox();
|
||||
expect(paneBox).not.toBeNull();
|
||||
expect(chipBox).not.toBeNull();
|
||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
||||
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Side panel", () => {
|
||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
|
||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||
const launcher = newTabPanel(sidePanel(page));
|
||||
await expect(launcher).toBeVisible();
|
||||
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
|
||||
"Changes",
|
||||
"Files",
|
||||
"Terminal",
|
||||
"Agent",
|
||||
]);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||
});
|
||||
|
||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||
});
|
||||
|
||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
||||
await changesTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
||||
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(newTabPanel(sidePanel(page))).toBeVisible();
|
||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
|
||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
||||
// new pane and leaves the one it came from empty.
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
||||
await splitTabOutOfMainPane(page, draftTab);
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const emptyMain = newTabPanel(mainPane(page));
|
||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||
|
||||
const newTabChip = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await newTabChip.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
|
||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(sidePanel(page)).toBeVisible();
|
||||
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||
// nothing reseeds a draft into it.
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page))
|
||||
.getByRole("button", { name: "Files", exact: true })
|
||||
.click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await draftTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-draft-close-"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Main now contains New and is the only pane the user can see. Closing its
|
||||
// tab cannot remove the final visible pane.
|
||||
const lastPane = newTabPanel(mainPane(page));
|
||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
||||
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
|
||||
const finalNewTab = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await finalNewTab.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(lastPane).toBeVisible();
|
||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||
|
||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,46 +41,37 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
await openHelpMenu(page);
|
||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
await closeSheet(page, "app-diagnostic-sheet");
|
||||
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
|
||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
await closeSheet(page, "app-diagnostic-sheet");
|
||||
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
test("opens support and release destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function expectBorderHighlight(
|
||||
page: Page,
|
||||
testID: string,
|
||||
hoverTarget: Locator,
|
||||
expectedWidth: string,
|
||||
) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await hoverTarget.hover();
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
||||
await expectBorderHighlight(
|
||||
page,
|
||||
"workspace-split-resize-handle",
|
||||
splitHandle.getByRole("separator"),
|
||||
"3px",
|
||||
);
|
||||
});
|
||||
@@ -146,20 +146,37 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
||||
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
await test.step("sends one RPC for each pin transition", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(3);
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -207,34 +224,6 @@ test.describe("Pin workspace shortcut", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
||||
// shadowed second handler is unobservable from outside by design.
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
|
||||
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("project shows workspace under it", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-git project shows directory name", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||
|
||||
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
});
|
||||
|
||||
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
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 side panel");
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
selectWorkspaceInSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
|
||||
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
|
||||
expect(result.error).toBeNull();
|
||||
}
|
||||
|
||||
test.describe("Workspace archive cache coherence", () => {
|
||||
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
|
||||
|
||||
await archiveWorkspaceOutsideTheApp(workspace);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
|
||||
// One retry is enough for the daemon to stabilize.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await test.step("creates a terminal in the worktree", async () => {
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
while (Date.now() - startedAt < 20_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
||||
while (Date.now() - startedAt < 90_000) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRestartDaemon(input: {
|
||||
paseoHome: string;
|
||||
origin: string;
|
||||
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
|
||||
}
|
||||
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => stopProcess(child),
|
||||
close: () => killProcessTree(child),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowHasOnlyIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
||||
if (indicator === input.indicator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
} else {
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
|
||||
);
|
||||
}
|
||||
|
||||
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
|
||||
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
|
||||
}
|
||||
|
||||
async function getSettingsShortcut(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||
await page.waitForURL((url) => url.pathname === route);
|
||||
}
|
||||
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
await test.step("opening the first split pane preserves the composer", async () => {
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-pane-retention-${Date.now()}`,
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expectNodeConnected(originalComposer!);
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
const originalComposer = await captureRenderedNode(composer);
|
||||
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await tab.click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||
await expect(renameInput).toBeVisible();
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await tab.click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||
await expect(renameInput).toBeVisible();
|
||||
|
||||
const settingsShortcut = await page.evaluate(() =>
|
||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||
);
|
||||
await page.keyboard.press(settingsShortcut);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(renameInput).not.toBeVisible();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalComposer, composer);
|
||||
const settingsShortcut = await getSettingsShortcut(page);
|
||||
await page.keyboard.press(settingsShortcut);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(renameInput).not.toBeVisible();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalComposer!, composer);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
|
||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
const originalComposer = await captureRenderedNode(composer);
|
||||
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
await openCompactSettings(page, workspaceRoute);
|
||||
await page.goBack();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
||||
);
|
||||
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||
await expectSameRenderedNode(originalComposer, composer);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
|
||||
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await test.step("switching tabs preserves the terminal", async () => {
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
await expectNodeConnected(originalTerminal);
|
||||
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-terminal-retention-${Date.now()}`,
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewTerminal(page);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
await test.step("opening Settings preserves the terminal", async () => {
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonTest, test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
closeSetupTab,
|
||||
@@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
@@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
@@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
||||
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-scripts-", {
|
||||
paseoConfig: {
|
||||
|
||||
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
|
||||
STATE_DIR="${PASEO_COMPOSER_KEYBOARD_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-composer-keyboard}"
|
||||
ARTIFACTS_DIR="${REPO_ROOT}/.dev/agent-device-artifacts/composer-keyboard-android"
|
||||
SESSION="${PASEO_COMPOSER_KEYBOARD_SESSION:-composer-keyboard-android}"
|
||||
APP_ID="${PASEO_COMPOSER_KEYBOARD_APP_ID:-sh.paseo.debug}"
|
||||
DEVICE="${PASEO_COMPOSER_KEYBOARD_DEVICE:-paseo-api35}"
|
||||
HELPER_IME="com.callstack.agentdevice.imehelper/.TestInputMethodService"
|
||||
GBOARD_IME="com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"
|
||||
ASSERT="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs"
|
||||
STALL_HERMES="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/stall-hermes.mjs"
|
||||
METRO_PORT="${PASEO_MOBILE_E2E_METRO_PORT:-8082}"
|
||||
DAEMON_HOST="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOST:-127.0.0.1:6770}"
|
||||
DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e2e-home}"
|
||||
SERVER_ID="${PASEO_COMPOSER_KEYBOARD_SERVER_ID:-}"
|
||||
MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
|
||||
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
|
||||
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
|
||||
|
||||
if [[ -z "${SERVER_ID}" ]]; then
|
||||
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
|
||||
echo "Missing ${DAEMON_HOME}/server-id; set PASEO_COMPOSER_KEYBOARD_SERVER_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ID="$(<"${DAEMON_HOME}/server-id")"
|
||||
fi
|
||||
|
||||
ad() {
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device "$@" --session "${SESSION}"
|
||||
}
|
||||
|
||||
capture_screen() {
|
||||
local output_path="$1"
|
||||
ad screenshot "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
snapshot_json() {
|
||||
local output_path="$1"
|
||||
ad snapshot -i --json >"${output_path}"
|
||||
}
|
||||
|
||||
capture_ui_xml() {
|
||||
local output_path="$1"
|
||||
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
|
||||
# Android exposes one UI Automation connection at a time. Release the
|
||||
# persistent agent-device snapshot helper before asking uiautomator for the
|
||||
# app and IME windows; agent-device reconnects it on the next interaction.
|
||||
adb shell am force-stop com.callstack.agentdevice.snapshothelper
|
||||
sleep 0.2
|
||||
adb shell uiautomator dump "${device_path}" >/dev/null
|
||||
adb pull "${device_path}" "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
clear_input() {
|
||||
ad fill 'editable=true' "x" --settle
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
clear_focused_input_with_adb() {
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
wait_for_ime() {
|
||||
local expected="$1"
|
||||
local previous_top=""
|
||||
local stable_count=0
|
||||
for _ in $(seq 1 30); do
|
||||
local window_state
|
||||
window_state="$(adb shell dumpsys window)"
|
||||
if [[ "${expected}" == "true" ]]; then
|
||||
local ime_top
|
||||
ime_top="$(printf '%s' "${window_state}" | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
if [[ -n "${ime_top}" && "${ime_top}" -gt 0 ]]; then
|
||||
if [[ "${ime_top}" == "${previous_top}" ]]; then
|
||||
stable_count="$((stable_count + 1))"
|
||||
else
|
||||
stable_count=0
|
||||
previous_top="${ime_top}"
|
||||
fi
|
||||
if [[ "${stable_count}" -ge 2 ]]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
elif ! printf '%s' "${window_state}" | rg -q "type=ime .*visible=true"; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "IME did not become visible=${expected}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ime_is_visible() {
|
||||
adb shell dumpsys window | rg -q "type=ime .*visible=true"
|
||||
}
|
||||
|
||||
open_gboard() {
|
||||
local x="$1"
|
||||
local y="$2"
|
||||
adb shell ime set "${GBOARD_IME}" >/dev/null
|
||||
if ime_is_visible; then
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
fi
|
||||
adb shell input tap "${x}" "${y}"
|
||||
wait_for_ime true
|
||||
local keyboard_shift
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${x}" "$((y - keyboard_shift))"
|
||||
}
|
||||
|
||||
read_keyboard_shift() {
|
||||
local ime_top
|
||||
local navigation_top
|
||||
ime_top="$(adb shell dumpsys window | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
navigation_top="$(adb shell dumpsys window | sed -n 's/.*type=navigationBars frame=\[0,\([0-9][0-9]*\)\].*/\1/p' | head -1)"
|
||||
printf '%s\n' "$((navigation_top - ime_top))"
|
||||
}
|
||||
|
||||
read_ime_top() {
|
||||
adb shell dumpsys window | sed -n \
|
||||
's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null 2>&1 || true
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
mkdir -p "${STATE_DIR}" "${ARTIFACTS_DIR}"
|
||||
|
||||
workspaces_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- workspace ls --json --host "${DAEMON_HOST}")"
|
||||
workspace_id="$(node -e '
|
||||
const workspaces = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
const cwd = process.argv[1];
|
||||
const workspace = workspaces.find((candidate) => candidate.cwd === cwd);
|
||||
if (!workspace) process.exit(1);
|
||||
process.stdout.write(workspace.workspaceId);
|
||||
' "${REPO_ROOT}" <<<"${workspaces_json}")"
|
||||
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device open "${APP_ID}" \
|
||||
--platform android \
|
||||
--device "${DEVICE}" \
|
||||
--session "${SESSION}"
|
||||
adb shell am force-stop "${APP_ID}"
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A${METRO_PORT}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
|
||||
--background \
|
||||
--title "${AGENT_TITLE}" \
|
||||
--provider mock \
|
||||
--model e2e-fast-stream \
|
||||
--workspace "${workspace_id}" \
|
||||
--cwd "${REPO_ROOT}" \
|
||||
--host "${DAEMON_HOST}" \
|
||||
--json)"
|
||||
agent_id="$(node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
process.stdout.write(result.agentId);
|
||||
' <<<"${run_json}")"
|
||||
sleep 3
|
||||
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 5
|
||||
# The development-client bootstrap can consume the first link while Expo Router is mounting.
|
||||
# Deliver the target again after the root navigator is live.
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 45000
|
||||
ad wait 'editable=true' 10000
|
||||
clear_input
|
||||
ad keyboard dismiss || true
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import sharp from "sharp";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
if (command === "rect") {
|
||||
const [snapshotPath, identifier] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
|
||||
);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
process.stdout.write(`${Math.round(x + width / 2)} ${Math.round(y + height / 2)} ${height}\n`);
|
||||
} else if (command === "above-y") {
|
||||
const [snapshotPath, upperIdentifier, lowerYArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const upper = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === upperIdentifier || candidate.label === upperIdentifier,
|
||||
);
|
||||
if (!upper) throw new Error(`Missing node: ${upperIdentifier}`);
|
||||
const upperBottom = upper.rect.y + upper.rect.height;
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(
|
||||
`${upperIdentifier} extends below the composer top: ${upperBottom} > ${lowerY}`,
|
||||
);
|
||||
}
|
||||
} else if (command === "xml-above-y") {
|
||||
const [snapshotPath, contentDescription, lowerYArgument] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const escapedDescription = contentDescription.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = snapshot.match(
|
||||
new RegExp(
|
||||
`<node[^>]*content-desc="${escapedDescription}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`,
|
||||
),
|
||||
);
|
||||
if (!match) throw new Error(`Missing node: ${contentDescription}`);
|
||||
const upperBottom = Number(match[4]);
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(`${contentDescription} extends below the keyboard: ${upperBottom} > ${lowerY}`);
|
||||
}
|
||||
} else if (command === "same-header") {
|
||||
const [baselinePath, keyboardPath, headerBottomArgument] = args;
|
||||
const headerBottom = Number(headerBottomArgument);
|
||||
const crop = { left: 0, top: 64, width: 1080, height: headerBottom - 64 };
|
||||
const [baseline, keyboard] = await Promise.all([
|
||||
sharp(baselinePath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(keyboardPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < baseline.length; index += 1) {
|
||||
if (Math.abs(baseline[index] - keyboard[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / baseline.length;
|
||||
if (changedRatio > 0.01) {
|
||||
throw new Error(`Header changed while the keyboard opened (${changedRatio.toFixed(3)})`);
|
||||
}
|
||||
} else if (command === "same-input-height") {
|
||||
const [firstPath, secondPath] = args;
|
||||
const readInputHeight = async (snapshotPath) => {
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
if (!input) throw new Error(`Missing composer input in ${snapshotPath}`);
|
||||
return input.rect.height;
|
||||
};
|
||||
const [firstHeight, secondHeight] = await Promise.all([
|
||||
readInputHeight(firstPath),
|
||||
readInputHeight(secondPath),
|
||||
]);
|
||||
if (firstHeight !== secondHeight) {
|
||||
throw new Error(`Composer height drifted from ${firstHeight} to ${secondHeight}`);
|
||||
}
|
||||
} else if (command === "has-exact-text") {
|
||||
const [snapshotPath, expected] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
|
||||
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
|
||||
}
|
||||
} else if (command === "same-region") {
|
||||
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
|
||||
const crop = {
|
||||
left: 0,
|
||||
top: Number(topArgument),
|
||||
width: 1080,
|
||||
height: Number(heightArgument),
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
sharp(firstPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(secondPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < first.length; index += 1) {
|
||||
if (Math.abs(first[index] - second[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / first.length;
|
||||
if (changedRatio > 0.02) {
|
||||
throw new Error(
|
||||
`${failureMessage ?? "Composer region changed during the JS stall"} (${changedRatio.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import process from "node:process";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const [, , metroPortArgument, durationArgument, appId] = process.argv;
|
||||
const metroPort = Number(metroPortArgument);
|
||||
const duration = Number(durationArgument);
|
||||
const targets = await fetch(`http://127.0.0.1:${metroPort}/json/list`).then((response) =>
|
||||
response.json(),
|
||||
);
|
||||
const target = targets.find((candidate) => candidate.appId === appId);
|
||||
if (!target) throw new Error(`Missing Hermes target for ${appId}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
const timeout = setTimeout(() => reject(new Error("Hermes stall timed out")), duration + 5000);
|
||||
socket.on("open", () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: "Runtime.evaluate",
|
||||
params: {
|
||||
expression: `(() => { const deadline = Date.now() + ${duration}; while (Date.now() < deadline) {} })()`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
socket.on("message", (message) => {
|
||||
const response = JSON.parse(message.toString());
|
||||
if (response.id !== 1) return;
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
@@ -32,23 +32,22 @@ const metroTest = base.extend({
|
||||
},
|
||||
});
|
||||
|
||||
const test = metroTest.extend<
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{
|
||||
paseoE2ESetup: void;
|
||||
projectOwnership: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
},
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
e2eForkProviders: string[];
|
||||
e2eInjectPaseoTools: boolean;
|
||||
e2eWorker: void;
|
||||
e2eWorkerClient: SeedDaemonClient;
|
||||
}
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({ e2eForkProviders }, provide, workerInfo) => {
|
||||
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
forkProviders: e2eForkProviders,
|
||||
injectPaseoTools: e2eInjectPaseoTools,
|
||||
});
|
||||
try {
|
||||
await provide();
|
||||
@@ -94,6 +93,16 @@ const test = metroTest.extend<
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const test = daemonTest.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
@@ -227,4 +236,4 @@ const test = metroTest.extend<
|
||||
},
|
||||
});
|
||||
|
||||
export { test, metroTest, expect, type Page };
|
||||
export { daemonTest, test, metroTest, expect, type Page };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { killProcessTree } from "./helpers/spawn-node";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
once(child, "exit"),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||
const appDir = path.resolve(__dirname, "../..");
|
||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
||||
const expoCli = require.resolve("expo/bin/cli");
|
||||
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
|
||||
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -222,11 +205,11 @@ export default async function globalSetup() {
|
||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||
|
||||
return async () => {
|
||||
await stopProcess(metroProcess);
|
||||
await killProcessTree(metroProcess);
|
||||
console.log("[e2e] Metro stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await stopProcess(metroProcess);
|
||||
await killProcessTree(metroProcess);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
||||
import { openChangesTreePanel } from "./workspace-tabs";
|
||||
|
||||
// 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
|
||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
||||
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||
// header keeps the matcher aligned with the row that owns repository identity.
|
||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
return page
|
||||
.getByTestId("changes-header")
|
||||
.getByTestId("changes-repository-header")
|
||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
}
|
||||
|
||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
||||
// Opens the fixed Explorer Changes view, where the switcher lives.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openChangesTreePanel(page);
|
||||
await expect(
|
||||
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||
|
||||
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectActiveChatOutlinePromptMovedFrom(
|
||||
page: Page,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
|
||||
await expect(activePrompt).toHaveCount(1);
|
||||
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
|
||||
}
|
||||
|
||||
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
export interface CommandCenterAgentControlChoice {
|
||||
query: string;
|
||||
choice: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
|
||||
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
|
||||
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
|
||||
*/
|
||||
export async function openCommandCenterForAgent(
|
||||
page: Page,
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||
): Promise<Locator> {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
const panel = await openCommandCenter(page);
|
||||
return panel.getByTestId("command-center-input");
|
||||
}
|
||||
|
||||
export async function chooseCommandCenterAgentControl(input: {
|
||||
page: Page;
|
||||
query: string;
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -49,6 +53,10 @@ export async function fillComposerDraft(page: Page, text: string): Promise<void>
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function typeIntoFocusedComposer(page: Page, text: string): Promise<void> {
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
import { killProcessTree } from "./spawn-node";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
close: () => killProcessTree(child),
|
||||
};
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
await killProcessTree(child);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,11 +20,19 @@ async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
const locator = process.platform === "win32" ? "where.exe" : "which";
|
||||
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
||||
.split(/\r?\n/u)
|
||||
.map((candidate) => candidate.trim())
|
||||
.filter(Boolean);
|
||||
realGhPath =
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
||||
) ?? "";
|
||||
} catch {
|
||||
// The local GitHub fixture remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -41,6 +50,9 @@ if (recordPath) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
@@ -106,6 +118,9 @@ process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -141,7 +156,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[] } = {},
|
||||
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
@@ -154,6 +169,9 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
if (options.injectPaseoTools) {
|
||||
await enablePaseoTools(paseoHome);
|
||||
}
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
preserveHome,
|
||||
@@ -187,3 +205,28 @@ export async function startE2EWorker(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePaseoTools(paseoHome: string): Promise<void> {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const existing = existsSync(configPath)
|
||||
? JSON.parse(await readFile(configPath, "utf8"))
|
||||
: { version: 1 };
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
daemon: {
|
||||
...existing.daemon,
|
||||
mcp: {
|
||||
...existing.daemon?.mcp,
|
||||
enabled: true,
|
||||
injectIntoAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user