mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 14:22:42 +00:00
Compare commits
223
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7a234e63 | ||
|
|
eb1a8a2ec7 | ||
|
|
d7b3ff654d | ||
|
|
c1558d29ea | ||
|
|
8d9a7123ba | ||
|
|
6017706cc1 | ||
|
|
62ab2deb27 | ||
|
|
48b310c8a2 | ||
|
|
5acfbb3777 | ||
|
|
4117dce2d3 | ||
|
|
8388f16186 | ||
|
|
5c2a1552df | ||
|
|
e0de0fdf6f | ||
|
|
8d424e039e | ||
|
|
d9b874f9d0 | ||
|
|
f56c013928 | ||
|
|
208e323035 | ||
|
|
ec7446add4 | ||
|
|
b30302b630 | ||
|
|
7aa3fac069 | ||
|
|
fd1e7a34ce | ||
|
|
ce0f21e2ff | ||
|
|
d172d060a5 | ||
|
|
2d86b94b2f | ||
|
|
d6d4084a06 | ||
|
|
16bb179e67 | ||
|
|
e08389065c | ||
|
|
80badb3b8f | ||
|
|
71f575e16a | ||
|
|
0708b8295c | ||
|
|
4d5c3c13d4 | ||
|
|
d3efddb0b5 | ||
|
|
0ed57187a9 | ||
|
|
a96c892302 | ||
|
|
74282dac12 | ||
|
|
12066431ad | ||
|
|
4d8bed9ddd | ||
|
|
b2ca7dcf03 | ||
|
|
8b1ba3ca58 | ||
|
|
e5124b039a | ||
|
|
5c108f345c | ||
|
|
9ab99a6e1e | ||
|
|
8ff2424da1 | ||
|
|
6db47b3c37 | ||
|
|
73221ce5bd | ||
|
|
6ad124758c | ||
|
|
f3ddf99fe3 | ||
|
|
404d08d24b | ||
|
|
6d43a8c5b0 | ||
|
|
e1a5664d9b | ||
|
|
b58af43a06 | ||
|
|
1bf3a57a1c | ||
|
|
56448842f0 | ||
|
|
14e6384516 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed | ||
|
|
a4b15455b7 | ||
|
|
bd9af256bd | ||
|
|
b1a638482d | ||
|
|
12aaded6c5 | ||
|
|
0614e96ee0 | ||
|
|
e135e9c101 | ||
|
|
335a11eb82 | ||
|
|
17562fcb66 | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 | ||
|
|
b923cde6cf | ||
|
|
058375dac8 | ||
|
|
eb051ea81e | ||
|
|
1839de5b1d | ||
|
|
ec6393f31f | ||
|
|
b5e51cf839 | ||
|
|
b0f054de90 | ||
|
|
e8108d9092 | ||
|
|
9eaf7ad0c1 | ||
|
|
be0acb2935 | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 | ||
|
|
8bc5f47b1c | ||
|
|
e1b01df905 | ||
|
|
7321d8a488 | ||
|
|
283a985fb6 | ||
|
|
8d03ccfbf0 | ||
|
|
acb3a8f207 | ||
|
|
a2b040869d | ||
|
|
99e4b53aa0 | ||
|
|
750cb82fbb | ||
|
|
9246813fd2 | ||
|
|
06f0522258 | ||
|
|
9b92b2e25c | ||
|
|
a310444c67 | ||
|
|
c364b16d40 | ||
|
|
dc7bc8db12 | ||
|
|
40f0393064 | ||
|
|
166aabd57b | ||
|
|
b852ae920b | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 | ||
|
|
114389383d | ||
|
|
804335bc00 | ||
|
|
e2fd5a9c57 | ||
|
|
b412374f0c | ||
|
|
a3f75aa9e4 | ||
|
|
46b1284177 | ||
|
|
a5dba3c992 | ||
|
|
dd0350bc2b | ||
|
|
cfb18f16d4 | ||
|
|
7f9b65d464 | ||
|
|
7e66657bf0 | ||
|
|
446749102e | ||
|
|
e61009d5fb | ||
|
|
ee0d901efb | ||
|
|
d5513472ed | ||
|
|
1228c01a10 | ||
|
|
563c014dc1 | ||
|
|
01f9bc8291 | ||
|
|
d0728cd1ed | ||
|
|
0ba4d2526e | ||
|
|
7868f5caf6 | ||
|
|
0147edaaac | ||
|
|
bd78e5efa4 | ||
|
|
e81d5d02b1 | ||
|
|
b63a325be3 | ||
|
|
6ec6633425 | ||
|
|
b26b9234c0 | ||
|
|
aec01ea605 | ||
|
|
a1da1b8f23 | ||
|
|
73390346ca | ||
|
|
718ed932b7 | ||
|
|
0009a7e717 | ||
|
|
29db2b7980 | ||
|
|
8c096b5e33 | ||
|
|
d0bc188a5b | ||
|
|
06fe97c901 | ||
|
|
f943e790b6 | ||
|
|
23d7a089e2 | ||
|
|
99fc204c55 | ||
|
|
33eb36c021 | ||
|
|
0fc6bf0c94 | ||
|
|
2d55870490 | ||
|
|
ab10a6694c | ||
|
|
2a151a4494 | ||
|
|
9fcae41c98 | ||
|
|
b352dad2e3 | ||
|
|
1ae979d3d0 | ||
|
|
d46164ce0d | ||
|
|
a591320409 | ||
|
|
ee6deda079 | ||
|
|
0555c877d0 | ||
|
|
b6454d7ea4 | ||
|
|
1b88f16124 | ||
|
|
1fae8e89f7 | ||
|
|
22488d4502 | ||
|
|
2a5264c188 | ||
|
|
8ddeb79c8d | ||
|
|
c90587e5b7 | ||
|
|
56f4fca9e4 | ||
|
|
28f7528588 | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 | ||
|
|
dc785bb29a | ||
|
|
93bfb34e21 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 | ||
|
|
f5c55dfc55 | ||
|
|
a700d7875c | ||
|
|
c301d6e8aa | ||
|
|
4ede81e841 | ||
|
|
543e3712e1 | ||
|
|
b7c8be4081 | ||
|
|
2974d7c960 | ||
|
|
a7f7405c04 | ||
|
|
2dbfd1e0f8 | ||
|
|
81a5e5bc61 | ||
|
|
0255738ee7 | ||
|
|
e3db7c3397 | ||
|
|
fe10c40b9a | ||
|
|
b6e8ca8974 | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f |
@@ -93,3 +93,6 @@ app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
|
||||
+77
-6
@@ -1,12 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project
|
||||
|
||||
## 0.11.3 - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
|
||||
- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper))
|
||||
|
||||
## 0.11.2 - 2026-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
|
||||
|
||||
## 0.11.1 - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
|
||||
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
|
||||
|
||||
## 0.11.0 - 2026-10-07
|
||||
|
||||
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
|
||||
|
||||
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
@@ -16,19 +61,26 @@
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
|
||||
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
|
||||
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
@@ -44,12 +96,18 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
|
||||
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
|
||||
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
|
||||
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
|
||||
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
|
||||
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
@@ -64,12 +122,14 @@
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
|
||||
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
|
||||
- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
|
||||
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -83,12 +143,18 @@
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
|
||||
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
|
||||
- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
|
||||
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
|
||||
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
|
||||
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
@@ -96,12 +162,16 @@
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
|
||||
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
|
||||
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
|
||||
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
@@ -121,6 +191,7 @@
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
|
||||
@@ -49,12 +49,14 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [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/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [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/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, 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 |
|
||||
|
||||
+13
-4
@@ -53,6 +53,17 @@
|
||||
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
||||
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
||||
|
||||
## スポンサー
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## はじめかた
|
||||
|
||||
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
||||
@@ -151,11 +162,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
## Paseo を支援する
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
|
||||
+13
-4
@@ -47,6 +47,17 @@
|
||||
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
|
||||
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
|
||||
|
||||
## 스폰서
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 시작하기
|
||||
|
||||
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
|
||||
@@ -161,11 +172,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
## Paseo 후원하기
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
|
||||
@@ -39,21 +39,44 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
|
||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
||||
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
||||
|
||||
## Sponsors
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="56"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
iOS, and Android. Write a plugin once and it is on your phone.
|
||||
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
||||
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
|
||||
- **Providers:** add a coding agent as a provider.
|
||||
|
||||
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
|
||||
|
||||
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
|
||||
|
||||
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -77,16 +100,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
|
||||
|
||||
To connect from your phone, open **Settings → your host → Pair Device**.
|
||||
|
||||
### CLI / headless
|
||||
### Server
|
||||
|
||||
Install the CLI and start Paseo:
|
||||
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
|
||||
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
|
||||
|
||||
For full setup and configuration, see:
|
||||
|
||||
@@ -195,11 +218,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
## Support Paseo
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
|
||||
|
||||
## Related projects
|
||||
|
||||
|
||||
+13
-4
@@ -53,6 +53,17 @@
|
||||
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
|
||||
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
|
||||
|
||||
## 赞助商
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 快速开始
|
||||
|
||||
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
|
||||
@@ -151,11 +162,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
+37
-9
@@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
@@ -84,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
Branch-name and commit-message generators still use private disposable internal runtimes.
|
||||
`AgentManager` does not persist them or their provider sessions, excludes them from public
|
||||
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
|
||||
its exact-ID result briefly to cover completion before a waiter attaches. This internal
|
||||
lifecycle is separate from public workspace visibility and is not a public creation option.
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
@@ -125,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
|
||||
history session contains only the read results and must release its provider process once replay
|
||||
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
|
||||
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
|
||||
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
|
||||
before returning. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
@@ -185,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
@@ -197,10 +224,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
||||
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
@@ -12,7 +12,7 @@ provider deltas (every provider streams incrementally)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ source-item plugin transforms → Markdown presentation / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
```
|
||||
|
||||
@@ -29,23 +29,8 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies a 32,000-character render cap on web; native keeps the complete message. The native renderer keeps closed blocks in a stable head and sends the paced suffix to a separate tail view during streaming; completion reunites them for selection. This stays inside the renderer, without promoting blocks into timeline history.
|
||||
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
@@ -54,16 +39,27 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||||
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||
silently stops.
|
||||
- **Streaming cost must not grow with revealed history.** Reading older messages must not
|
||||
make the next reply progressively more expensive to display. Reuse unchanged history work
|
||||
while text arrives, and release obsolete window caches as the reader reveals more history;
|
||||
window caches must stay bounded while their source timeline remains loaded.
|
||||
|
||||
## Native parsing cost
|
||||
|
||||
The native library reparses and measures its entire `markdown` prop on each update. During streaming, the renderer splits source at an mdast block boundary and memoizes the head, so per-frame updates parse only the tail. Boundary discovery runs when a source line completes, rather than on every reveal frame. The unfinished line and final parsed block stay in the tail, preserving list continuation, setext headings, tables and open fences. Reference-dependent source and HTML retain a larger tail because later source can change their meaning. A single long block therefore still carries its full parsing cost.
|
||||
|
||||
Completion restores one native text view per Mermaid-delimited segment, so selection crosses prose paragraphs again. The head retains its trailing block margin while split. JS reveal remains the pacing source; native opacity animation stays disabled.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||||
- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
|
||||
|
||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||||
|
||||
|
||||
+3
-1
@@ -124,7 +124,9 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
|
||||
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
|
||||
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
|
||||
@@ -345,6 +345,10 @@ join active work or reuse committed stages. A persisted resource alone cannot pr
|
||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
||||
receipt return an unknown outcome instead of being repeated.
|
||||
|
||||
Agent registration commits creation. Rejecting the first prompt is a turn error on that
|
||||
agent; reporting creation failure leaves an orphaned agent and invites another Create.
|
||||
Receipt replay must not resend the prompt.
|
||||
|
||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
||||
|
||||
@@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
|
||||
+4
-1
@@ -589,7 +589,10 @@ host, kinds, and ids. Opening a cached workspace uses exact workspace and projec
|
||||
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.
|
||||
checkpoint are also applied in one transaction. Reads wait for accepted changes to reach storage,
|
||||
then retry only when a requested row changes during the read. Unrelated agent streams do not
|
||||
invalidate the read. Directory baseline replacements and host removal or renaming still invalidate
|
||||
the affected reads.
|
||||
|
||||
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
|
||||
|
||||
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Desktop Linux hidden-window fallback
|
||||
|
||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
||||
forever without a single compositor frame: the renderer loads and runs, but
|
||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
||||
it did not start while every process is alive. `showWindowWhenReady`
|
||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
||||
the document finishes loading if the event has not arrived. The fallback is
|
||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
||||
|
||||
Run the startup regression on Linux with an isolated display:
|
||||
|
||||
```bash
|
||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -7,6 +7,7 @@ contracts.
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| 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 |
|
||||
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -22,6 +23,36 @@ 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.
|
||||
|
||||
## Sheets
|
||||
|
||||
On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
|
||||
keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
|
||||
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
|
||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
|
||||
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
|
||||
|
||||
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
|
||||
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
|
||||
the sheet's tab into a pane, and edits the autosave has not started writing are lost.
|
||||
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
@@ -62,7 +93,9 @@ lifecycle.
|
||||
|
||||
`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.
|
||||
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
|
||||
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
|
||||
pane, so callers never check whether splits render.
|
||||
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
|
||||
+58
-6
@@ -198,6 +198,46 @@ viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
|
||||
fullscreen` fills the stationary viewport with the composer slot over the
|
||||
still-mounted content, paints its own surface under the safe areas, and turns
|
||||
the surface translation off. The composer's single tree keeps the editing
|
||||
surface at the same position in both modes, so the mounted editor, its native
|
||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
||||
the live input. A viewport crossing the compact breakpoint still remounts it:
|
||||
the app shell swaps its gesture wrapper and the workspace screen
|
||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
||||
restores the text and the editing session restores the caret. Focus restoration
|
||||
is limited to mouse-driven UIs, using the same fine-pointer policy as autofocus.
|
||||
New workspace keys its sessions by draft in the app-level store for the same
|
||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
||||
global exit action (Escape, hardware back) is registered only for the active
|
||||
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
|
||||
sheet suspends pane focus, and the composer gives up input from the moment the
|
||||
user asks for the compact agent list or explorer, before the panel's motion
|
||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
||||
list must not clear the attention it shows. On mouse-driven UIs, focus restoration
|
||||
follows pane focus, not the active composer. Keying it on the active composer
|
||||
made sidebar dismissal look like a hidden-tab blur and refocused on close.
|
||||
Native and touch-web navigation must not replay old editor focus, including
|
||||
when blur arrives after pane deactivation or the editor unmounts before blur.
|
||||
Fullscreen toggles keep the editor mounted and need no focus restoration.
|
||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
||||
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
|
||||
height as a bottom margin at move start and end (boundary-time, like the
|
||||
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
|
||||
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
|
||||
and without animating the editor's height. The screen that owns the chrome
|
||||
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
|
||||
active tab and New workspace reads the mounted composer's session. Hosts that
|
||||
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
|
||||
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
|
||||
registration, focus untouched) and the Android back button leave fullscreen.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
@@ -302,9 +342,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
|
||||
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
|
||||
close. This includes native centered dialogs: separate React Native Modals cannot present a
|
||||
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
|
||||
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
|
||||
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
|
||||
portal host and container measurement while the sheet still joins the same stack. That export is
|
||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
||||
|
||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
||||
close path then parks the sheet at index -1 with its content mounted; the owner hears
|
||||
`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
|
||||
sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
|
||||
unmounts it, so its backdrop and Back press call `close()`.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
@@ -314,9 +365,10 @@ forwards commands to the current Gorhom handle.
|
||||
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
|
||||
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
|
||||
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
|
||||
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
|
||||
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal
|
||||
(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
|
||||
authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
|
||||
same push/replace presentation semantics.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
|
||||
+4
-2
@@ -21,6 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **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).
|
||||
@@ -33,8 +35,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Markdown rendering
|
||||
|
||||
Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and changelog content. It uses `react-native-enriched-markdown` on iOS, Android and web. Keep source adaptation and renderer configuration in `components/markdown`; consumers supply Markdown and, when needed, its file location.
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars.
|
||||
|
||||
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Comment HTML is normalized to Markdown before parsing; details show their summary and body expanded.
|
||||
|
||||
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
|
||||
|
||||
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
|
||||
|
||||
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
||||
|
||||
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
|
||||
|
||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
Follow [QA](qa.md) and [mobile testing](mobile-testing.md). Exercise chat, file preview, plan and PR content on all three platforms. Include cross-paragraph selection, progressive streaming, a file-link tap, code copy/highlighting, a table and a workspace image. On iPad, repeat preview selection after opening and closing Explorer and after rotation. Check web find, selection-copy and source-message navigation after DOM changes.
|
||||
@@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the
|
||||
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
|
||||
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
|
||||
|
||||
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
|
||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
||||
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
|
||||
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
@@ -193,6 +198,9 @@ its own.
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
|
||||
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
|
||||
- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its
|
||||
retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the
|
||||
menu would reappear with the panel on the next tab switch.
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Native text selection
|
||||
|
||||
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
||||
|
||||
## Android inverted chat
|
||||
|
||||
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
|
||||
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
|
||||
into rotation with positive `scaleY`, so test the composed matrix when deciding
|
||||
whether those requests should move the viewport. Native tests must apply the
|
||||
list's transform through React Native's view manager; assigning `scaleY` directly
|
||||
misses that decomposition.
|
||||
|
||||
## Diagrams in lists and quotes
|
||||
|
||||
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
||||
|
||||
Reopening a list marker or quote prefix on the following fragment cannot place the diagram inside the original container or continue its quote rule. Preserving the group requires a native block renderer hook or a host that owns the whole group. Keep native diagrams at the top level until that exists; nested grouping remains a known limitation.
|
||||
|
||||
## Leading inline images
|
||||
|
||||
Native image decoders do not support SVG badges, so the renderer shows their alt text. Pending images share the web placeholder: a reserved image box with a small centred image glyph; confirmed acquisition or decoding failures show alt text.
|
||||
|
||||
The native library classifies an image from the text preceding it. A raster image at the start of a paragraph uses block sizing even when text follows; the inline size setting only affects images the library recognizes as inline. Correcting that classification requires a native library change and new app builds. JavaScript-only updates retain that classification until native app builds include a library fix.
|
||||
+24
-13
@@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun
|
||||
appear in the plugin catalog. Editing one in development requires a daemon restart.
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
A provider registration with `requiresConfiguration: true` participates only while its ID exists
|
||||
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
|
||||
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
|
||||
provider does not enter executable resolution or catalog discovery.
|
||||
|
||||
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
|
||||
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
|
||||
availability and a diagnostic after the daemon resolves the executable. Configure command,
|
||||
@@ -117,15 +123,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
|
||||
## Install from a registry
|
||||
|
||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
||||
|
||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
||||
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
|
||||
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
Registry overviews are untrusted author content, even before installation. Render them with
|
||||
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
@@ -138,13 +148,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL. An existing directory still wins over source resolution.
|
||||
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL.
|
||||
|
||||
```bash
|
||||
paseo plugin install owner/repository
|
||||
paseo plugin install git:owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install owner/monorepo:plugins/review
|
||||
paseo plugin install git:owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
@@ -155,8 +165,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
|
||||
Only the daemon reads plugin files and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
@@ -488,7 +499,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
|
||||
+3
-1
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
@@ -103,7 +105,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
|
||||
+19
-5
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
|
||||
message.
|
||||
|
||||
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
|
||||
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
|
||||
row is recorded before handler output. The app tracks submission transactions only for hosts with this
|
||||
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
|
||||
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
|
||||
the Paseo timeline, so one visible response can span several canonical turns without a user message
|
||||
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
|
||||
do not imply local ownership or authorization. Capture the sender title at delivery: archived or
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
|
||||
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
the wire. A correlated provider echo records the provider's native identity internally without
|
||||
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -86,6 +86,12 @@ buildNpmPackage {
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
# Metro's worker runs out of heap transforming the generated
|
||||
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
|
||||
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
|
||||
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
|
||||
# only raises the limit on smaller builders, like desktop-release.yml does.
|
||||
NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
|
||||
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
|
||||
|
||||
Generated
+70
-96
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -16265,15 +16265,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
@@ -17088,15 +17079,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
@@ -17122,17 +17104,6 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
@@ -30248,6 +30219,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -30259,6 +30231,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
@@ -30905,15 +30878,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
|
||||
"license": "Beerware",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||
@@ -30939,22 +30903,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-native-fit-image": "^1.5.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.2.0",
|
||||
"react-native": ">=0.50.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
|
||||
@@ -31020,18 +30968,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-uitextview": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz",
|
||||
"integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-unistyles": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
||||
@@ -36413,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36480,7 +36416,11 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mdast-util-from-markdown": "2.0.3",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"micromark-extension-gfm-table": "^2.1.2",
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -36490,15 +36430,14 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -37350,6 +37289,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
|
||||
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
@@ -37378,6 +37334,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/app/node_modules/react-native-enriched-markdown": {
|
||||
"version": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz",
|
||||
"integrity": "sha512-PyYAAVJAyjlcqHXgSnzxok13MDmyxs/nt46hHIOBdCc0prfaL2Ef6nYkZu7fFCeaETOJXd/xQr4n2/79I+gvdA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"katex": ">=0.16.0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"katex": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
|
||||
@@ -37496,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/server": "0.11.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37721,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +37979,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38904,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38921,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38944,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/highlight": "0.11.2",
|
||||
"@getpaseo/plugin": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,7 +40119,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -40174,6 +40147,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
@@ -41753,7 +41727,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
withAndroidOptionalCamera,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
|
||||
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
|
||||
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
|
||||
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
|
||||
const { compileModsAsync } = requireFromExpoCli(
|
||||
"@expo/config-plugins/build/plugins/mod-compiler.js",
|
||||
);
|
||||
|
||||
interface ManifestEntry {
|
||||
$: Record<string, string>;
|
||||
}
|
||||
|
||||
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
|
||||
|
||||
async function resolveAndroidManifest() {
|
||||
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
|
||||
await compileModsAsync(exp, {
|
||||
projectRoot: __dirname,
|
||||
introspect: true,
|
||||
platforms: ["android"],
|
||||
assertMissingModProviders: false,
|
||||
});
|
||||
return exp._internal.modResults.android.manifest.manifest;
|
||||
}
|
||||
|
||||
describe("Android app config", () => {
|
||||
afterEach(() => {
|
||||
if (originalFdroidFlag === undefined) {
|
||||
delete process.env.PASEO_FDROID_BUILD;
|
||||
} else {
|
||||
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Google Play", "0"],
|
||||
["F-Droid", "1"],
|
||||
])(
|
||||
"%s build installs on devices without a camera",
|
||||
async (_build, fdroidFlag) => {
|
||||
process.env.PASEO_FDROID_BUILD = fdroidFlag;
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const permissions = (manifest["uses-permission"] ?? []).map(
|
||||
(entry: ManifestEntry) => entry.$["android:name"],
|
||||
);
|
||||
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
|
||||
|
||||
expect(permissions).toContain("android.permission.CAMERA");
|
||||
expect(features).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ "android:name": "android.hardware.camera", "android:required": "false" },
|
||||
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,24 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
@@ -16,8 +27,13 @@ const ACP_PROVIDER = {
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { hopliteProvider?: boolean; plugins?: boolean };
|
||||
} | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { providers?: Record<string, unknown> } }>;
|
||||
getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-catalog-e2e",
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
|
||||
}, theme);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-added-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await removeHoplite(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-unsupported",
|
||||
port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
|
||||
const before = await client.getDaemonConfig();
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProviderArea(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
await expect(errorToast).toBeInViewport();
|
||||
expect((await client.getDaemonConfig()).config).toEqual(before.config);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-picker",
|
||||
});
|
||||
const profiles = await seedAgentProfiles([
|
||||
{ id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
|
||||
]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
|
||||
try {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-provider-mock")).toBeVisible();
|
||||
await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
|
||||
await closeModelPicker(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profiles.restore();
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
chooseAddProjectMethod,
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "../support/helpers/hosts";
|
||||
@@ -214,6 +217,60 @@ test.describe("Add Project command-center flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
|
||||
const hosts: IsolatedHostDaemon[] = [];
|
||||
try {
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 360 });
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(
|
||||
page,
|
||||
hosts.map((host, index) => ({
|
||||
serverId: host.serverId,
|
||||
port: host.port,
|
||||
label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
|
||||
})),
|
||||
{ primaryLabel: "Fleet 00" },
|
||||
);
|
||||
for (const host of hosts) {
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: host.serverId,
|
||||
endpoint: `localhost:${host.port}`,
|
||||
});
|
||||
}
|
||||
await openAddProjectHostSelection(page);
|
||||
await expect(
|
||||
addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
|
||||
).toBeVisible();
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
|
||||
// Filtering out the first host moves retained rows to new positions.
|
||||
await addProjectFlowInput(page).fill("scroll-host");
|
||||
await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Other host");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expectAddProjectResultVisible(page, "Fleet 00");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlow(page)).toContainText("Fleet 02");
|
||||
} finally {
|
||||
await Promise.all(hosts.map((host) => host.close()));
|
||||
}
|
||||
});
|
||||
|
||||
test("keyboard directory search adds the selected Project", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
@@ -223,6 +280,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
@@ -240,6 +298,59 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("keyboard navigation keeps the selected row inside the visible results", async ({
|
||||
page,
|
||||
}) => {
|
||||
const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
|
||||
const rowPath = (index: number) =>
|
||||
path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
const results = page.getByTestId("add-project-flow-results");
|
||||
await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await navigateAddProjectResults(page, "ArrowUp", 12);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowUp", 1);
|
||||
await expectAddProjectResultVisible(page, rowPath(15));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
|
||||
await expect(results.getByRole("button")).toHaveCount(16);
|
||||
await expectAddProjectResultVisible(page, rowPath(0));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(12));
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
await expect(results.getByRole("button")).toHaveCount(17);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await page.keyboard.press("Enter");
|
||||
projectId = await expectOpenedProject(page, "scroll-probe-11");
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: "scroll-probe-11",
|
||||
projectPath: rowPath(11),
|
||||
});
|
||||
} finally {
|
||||
if (projectId) await removeCreatedProject(rowPath(11), projectId);
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
if (input.preservesActiveTurn) {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
return;
|
||||
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
|
||||
|
||||
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toHaveCount(1);
|
||||
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(attempts).toHaveCount(2);
|
||||
const userMessage = attempts.last();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
|
||||
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
@@ -1147,17 +1154,15 @@ test.describe("Agent message submission", () => {
|
||||
page,
|
||||
page.getByTestId("assistant-message").last(),
|
||||
);
|
||||
const assistantMessageCount = await page.getByTestId("assistant-message").count();
|
||||
const toolCallCount = await page.getByTestId("tool-call-badge").count();
|
||||
await composer.press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("assistant-message").count())
|
||||
.toBeGreaterThan(assistantMessageCount);
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("tool-call-badge").count())
|
||||
.toBeGreaterThan(toolCallCount);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({
|
||||
hasText: "I'll take a look at the scroll anchor behavior you described.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await awaitToolCall(page, "Read");
|
||||
await finishTimelineRowStabilityCheck();
|
||||
});
|
||||
|
||||
@@ -1173,13 +1178,14 @@ test.describe("Agent message submission", () => {
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
test("retains a rejected submission before its error and accepts its retry", async ({
|
||||
page,
|
||||
rejectionScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this rejected submission.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
|
||||
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
@@ -1209,6 +1215,7 @@ test.describe("Agent message submission", () => {
|
||||
errorMessage: "Requested mock steer transport failure",
|
||||
preservesActiveTurn: true,
|
||||
});
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
|
||||
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
||||
import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
seedLongMockAgentTimeline,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
expectTimelinePromptVisible,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
sampleStreamFrames,
|
||||
summarizeStreamSmoothness,
|
||||
@@ -102,6 +109,40 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
||||
});
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await submitMessage(page, "Stream a long answer after reading older messages.");
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
for (let sample = 0; sample < 3; sample += 1) {
|
||||
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
|
||||
const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
||||
const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
|
||||
const react = summarizeReactCommits(await readStreamReactCommits(page));
|
||||
await testInfo.attach(`revealed-history-stream-${sample}`, {
|
||||
body: JSON.stringify({ smoothness, react }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
|
||||
expect(smoothness.growthFrames).toBeGreaterThan(20);
|
||||
expect(react.count).toBeGreaterThan(20);
|
||||
expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readStreamReactCommits(
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
openDiagramFullscreen,
|
||||
expectFullscreenDiagram,
|
||||
} from "../support/helpers/diagram";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
@@ -16,6 +25,11 @@ import {
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
rememberTimelineViewport,
|
||||
scrollTimelinePromptIntoView,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
imageWorkspace: async ({ page: _page }, provide) => {
|
||||
@@ -28,6 +42,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
},
|
||||
});
|
||||
|
||||
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Angle destination image",
|
||||
fileName: "My image Мои.png",
|
||||
});
|
||||
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
const encodedImage = { ...image, alt: "Encoded destination image" };
|
||||
await emitSettledAssistantImage(
|
||||
workspace.client,
|
||||
agent,
|
||||
encodedImage,
|
||||
encodeURI(path.join(workspace.repoPath, image.relativePath)),
|
||||
);
|
||||
await expectAssistantImageRendered(page, encodedImage);
|
||||
|
||||
const literalPercentImage = await createSmallAssistantPng(workspace, {
|
||||
alt: "Literal percent filename image",
|
||||
fileName: "literal%20name.png",
|
||||
});
|
||||
await emitSettledAssistantImage(
|
||||
workspace.client,
|
||||
agent,
|
||||
literalPercentImage,
|
||||
encodeURI(literalPercentImage.relativePath),
|
||||
);
|
||||
await expectAssistantImageRendered(page, literalPercentImage);
|
||||
});
|
||||
|
||||
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
@@ -60,11 +110,11 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.getByRole("button", { name: "Open image attachment" }).click();
|
||||
await page.getByRole("button", { name: image.alt }).click();
|
||||
const lightboxImage = page.getByTestId("attachment-lightbox-image");
|
||||
const canvas = page.getByTestId("attachment-lightbox-canvas");
|
||||
await expect(lightboxImage).toBeVisible();
|
||||
await canvas.hover();
|
||||
await page.getByTestId("zoomable-viewport-toolbar").hover();
|
||||
const transformedContent = canvas.locator(":scope > div").first();
|
||||
const initialBox = await transformedContent.boundingBox();
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
@@ -96,7 +146,7 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
@@ -106,119 +156,34 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
fileName: "reload-geometry-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
try {
|
||||
delivery.release();
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image, delivery.url);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.addInitScript(
|
||||
({ accessibleName }) => {
|
||||
interface ImageGeometryEvent {
|
||||
atMs: number;
|
||||
complete: boolean;
|
||||
height: number;
|
||||
naturalHeight: number;
|
||||
naturalWidth: number;
|
||||
scrollTop: number;
|
||||
width: number;
|
||||
}
|
||||
const state = {
|
||||
accessibleName,
|
||||
events: [] as ImageGeometryEvent[],
|
||||
mutationObserver: null as MutationObserver | null,
|
||||
resizeObserver: null as ResizeObserver | null,
|
||||
target: null as Element | null,
|
||||
};
|
||||
const record = () => {
|
||||
const target = state.target;
|
||||
if (!target) return;
|
||||
const imageElement =
|
||||
target instanceof HTMLImageElement ? target : target.querySelector("img");
|
||||
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const event: ImageGeometryEvent = {
|
||||
atMs: performance.now(),
|
||||
complete: imageElement?.complete ?? false,
|
||||
height: rect.height,
|
||||
naturalHeight: imageElement?.naturalHeight ?? 0,
|
||||
naturalWidth: imageElement?.naturalWidth ?? 0,
|
||||
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
|
||||
width: rect.width,
|
||||
};
|
||||
const previous = state.events.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
previous.complete !== event.complete ||
|
||||
previous.height !== event.height ||
|
||||
previous.naturalHeight !== event.naturalHeight ||
|
||||
previous.naturalWidth !== event.naturalWidth ||
|
||||
previous.scrollTop !== event.scrollTop ||
|
||||
previous.width !== event.width
|
||||
) {
|
||||
state.events.push(event);
|
||||
}
|
||||
};
|
||||
const inspect = () => {
|
||||
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName,
|
||||
);
|
||||
if (!target || target === state.target) {
|
||||
record();
|
||||
return;
|
||||
}
|
||||
state.target = target;
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(record);
|
||||
state.resizeObserver.observe(target);
|
||||
record();
|
||||
};
|
||||
const start = () => {
|
||||
state.mutationObserver = new MutationObserver(inspect);
|
||||
state.mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["src", "style"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
inspect();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: typeof state;
|
||||
}
|
||||
).__paseoReloadImageGeometry = state;
|
||||
},
|
||||
{ accessibleName: image.alt },
|
||||
);
|
||||
delivery.holdNextRequest();
|
||||
await page.reload();
|
||||
await delivery.waitUntilRequested();
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
|
||||
await scrollTimelinePromptIntoView(
|
||||
page,
|
||||
"image-history-turn-2: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
const beforeLoad = await rememberTimelineViewport(page);
|
||||
const reservedHeight = (await rendered.boundingBox())?.height;
|
||||
expect(reservedHeight).toBe(320);
|
||||
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.reload();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: { events: Array<unknown> };
|
||||
}
|
||||
).__paseoReloadImageGeometry;
|
||||
if (!state) throw new Error("Reload image geometry observer was not installed");
|
||||
return state.events;
|
||||
});
|
||||
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
|
||||
const initialGeometry = geometryEvents.find((event) => event.height > 0);
|
||||
if (!initialGeometry)
|
||||
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
|
||||
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
|
||||
if (!expandedGeometry)
|
||||
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
|
||||
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
|
||||
initialGeometry.scrollTop,
|
||||
);
|
||||
delivery.release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
expect((await rendered.boundingBox())?.height).toBe(reservedHeight);
|
||||
await expect.poll(() => rememberTimelineViewport(page)).toEqual(beforeLoad);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
@@ -259,3 +224,222 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
|
||||
response: "stress-update-0",
|
||||
});
|
||||
});
|
||||
|
||||
test("a later reply reads new bytes at the same workspace image path", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
|
||||
const first = await createSmallAssistantPng(workspace, {
|
||||
alt: "Earlier screenshot",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, first);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await expectAssistantImageRendered(page, first);
|
||||
const second = await createSmallAssistantPng(workspace, {
|
||||
alt: "Updated screenshot",
|
||||
fileName: "screenshot.png",
|
||||
width: 2,
|
||||
height: 3,
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, second);
|
||||
await expectAssistantImageRendered(page, second);
|
||||
await expectAssistantImageRendered(page, first);
|
||||
});
|
||||
|
||||
test("pending assistant images reserve their decoded height", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Delayed remote image",
|
||||
fileName: "delayed.png",
|
||||
width: 32,
|
||||
height: 24,
|
||||
});
|
||||
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
const { url } = delivery;
|
||||
try {
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, url);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
|
||||
await delivery.waitUntilRequested();
|
||||
const pendingBox = await rendered.boundingBox();
|
||||
expect(pendingBox?.height).toBe(320);
|
||||
await rendered.click();
|
||||
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-image.png") });
|
||||
delivery.release();
|
||||
await expect(rendered).toHaveAttribute("src", url);
|
||||
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** Serve the fixture's real PNG bytes; release controls when the decoder can finish. */
|
||||
async function serveDelayedImage(file: string) {
|
||||
let release!: () => void;
|
||||
let ready: Promise<void>;
|
||||
let requested!: () => void;
|
||||
let requestStarted: Promise<void>;
|
||||
const holdNextRequest = () => {
|
||||
ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
requestStarted = new Promise<void>((resolve) => {
|
||||
requested = resolve;
|
||||
});
|
||||
};
|
||||
holdNextRequest();
|
||||
const server = createServer((_request, response) => {
|
||||
requested();
|
||||
void ready.then(() => {
|
||||
response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
|
||||
return createReadStream(file).pipe(response);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/image.png`,
|
||||
release: () => release(),
|
||||
holdNextRequest,
|
||||
waitUntilRequested: () => requestStarted,
|
||||
close: async () => {
|
||||
release();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("late workspace image completion preserves selected prose", async ({
|
||||
imageWorkspace,
|
||||
page,
|
||||
}) => {
|
||||
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
|
||||
const selected = await selectImageFixtureParagraphs(page);
|
||||
release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await expectSelectedParagraphsRetained(page, selected);
|
||||
});
|
||||
});
|
||||
|
||||
test("late workspace image completion keeps Mermaid fullscreen open", async ({
|
||||
imageWorkspace,
|
||||
page,
|
||||
}) => {
|
||||
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Done"]);
|
||||
release();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await expectFullscreenDiagram(page, ["Start", "Done"]);
|
||||
});
|
||||
});
|
||||
|
||||
async function withPendingWorkspaceImage(
|
||||
page: Page,
|
||||
workspace: SeededWorkspace,
|
||||
run: (
|
||||
image: Awaited<ReturnType<typeof createSmallAssistantPng>>,
|
||||
release: () => void,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Late workspace image",
|
||||
fileName: "late.png",
|
||||
width: 32,
|
||||
height: 24,
|
||||
});
|
||||
const source = [
|
||||
"Paragraph one stays selected while the workspace image loads.",
|
||||
"Paragraph two remains in this reply.",
|
||||
"Paragraph three is the selection endpoint.",
|
||||
"```mermaid\nflowchart LR\n Start --> Done\n```",
|
||||
``,
|
||||
].join("\n\n");
|
||||
const agent = await createSettledMockAgent(workspace, "Delayed workspace read", source);
|
||||
await emitSettledAssistantImage(workspace.client, agent, image);
|
||||
const gate = await delayFileReadResponse(page, image.relativePath);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
gate.release();
|
||||
}
|
||||
};
|
||||
try {
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await gate.waitUntilHeld();
|
||||
await expectCompletedDiagram(page, ["Start", "Done"]);
|
||||
await run(image, release);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
type SelectionProofWindow = Window & { __lateImageSelection?: { start: Node; end: Node } };
|
||||
|
||||
async function selectImageFixtureParagraphs(page: Page): Promise<string> {
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await message
|
||||
.getByText("Paragraph one stays selected while the workspace image loads.", { exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
const bounds = await message.evaluate((root) => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let start: Text | undefined;
|
||||
let end: Text | undefined;
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode as Text;
|
||||
if (text.data.startsWith("Paragraph one stays")) start = text;
|
||||
if (text.data.startsWith("Paragraph three is")) end = text;
|
||||
}
|
||||
if (!start || !end) throw new Error("Selection paragraphs are missing");
|
||||
(window as SelectionProofWindow).__lateImageSelection = { start, end };
|
||||
const first = document.createRange();
|
||||
first.selectNodeContents(start);
|
||||
const last = document.createRange();
|
||||
last.selectNodeContents(end);
|
||||
return {
|
||||
first: first.getBoundingClientRect().toJSON(),
|
||||
last: last.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
await page.mouse.move(bounds.first.x + 1, bounds.first.y + 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.last.right - 1, bounds.last.bottom - 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("Paragraph one");
|
||||
expect(selected).toContain("Paragraph three");
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function expectSelectedParagraphsRetained(page: Page, selected: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const proof = (window as SelectionProofWindow).__lateImageSelection;
|
||||
return {
|
||||
startConnected: proof?.start.isConnected,
|
||||
endConnected: proof?.end.isConnected,
|
||||
text: window.getSelection()?.toString(),
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ startConnected: true, endConnected: true, text: selected });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
reportScrollJumps,
|
||||
scrollCadences,
|
||||
withVariedTimeline,
|
||||
withStreamingReader,
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
@@ -133,6 +135,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
@@ -150,3 +204,22 @@ for (const cadence of scrollCadences) {
|
||||
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
|
||||
await expectImageSpaceReserved(page, testInfo);
|
||||
});
|
||||
|
||||
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
|
||||
await withStreamingReader(async (agent) => {
|
||||
const reading = await holdParagraphThroughCompletion(page, agent);
|
||||
expect(
|
||||
reading.textGrowth,
|
||||
"the held reader must observe an actively growing response",
|
||||
).toBeGreaterThan(1000);
|
||||
expect(
|
||||
reading.maxFollowGap,
|
||||
"follow-bottom must catch each streamed commit before the next frame",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
reading.maxDrift,
|
||||
"streaming must preserve the held paragraph's viewport position",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openAppearance(page: Page) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function setFontFamily(page: Page, name: string, family: string) {
|
||||
const input = page.getByRole("textbox", { name });
|
||||
await input.fill(family);
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
|
||||
await openAppearance(page);
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
|
||||
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
|
||||
);
|
||||
});
|
||||
await openAppearance(page);
|
||||
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
});
|
||||
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
test("caps a newline-heavy assistant message on web before rendering markdown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -73,9 +73,14 @@ const ASSISTANT_MARKDOWN = [
|
||||
].join("\n");
|
||||
|
||||
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
||||
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
|
||||
"",
|
||||
"www.example.com stays",
|
||||
"[www.example.com](http://www.example.com) stays",
|
||||
)
|
||||
.replace(
|
||||
"`https://example.com/generated` stays",
|
||||
"[`https://example.com/generated`](https://example.com/generated) stays",
|
||||
)
|
||||
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
|
||||
.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
@@ -98,8 +103,7 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
* Locate assistant rows by source-message identity.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
@@ -155,6 +159,50 @@ async function selectAssistantElementFromEndOf(
|
||||
);
|
||||
}
|
||||
|
||||
/** A drag over an image alone selects it inside its rendered frame. */
|
||||
async function selectAssistantImage(page: Page): Promise<void> {
|
||||
await assistantMessageBlocks(page)
|
||||
.locator("img")
|
||||
.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNode(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
}
|
||||
|
||||
/** Press in the gap below the image and drag to the end of `text` in the block after it. */
|
||||
async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const target = blocks.getByText(text);
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const frame = await blocks.locator("img").boundingBox();
|
||||
const end = await target.evaluate((element, selectedText) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const offset = node.textContent?.indexOf(selectedText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + selectedText.length);
|
||||
const box = range.getBoundingClientRect();
|
||||
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find ${selectedText}`);
|
||||
}, text);
|
||||
if (!frame) {
|
||||
throw new Error("Expected a rendered image");
|
||||
}
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.mouse.move(end.left + 1, end.y - 14);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -167,18 +215,12 @@ async function selectAssistantListItemFromMarker(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.locator(listTag)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.locator(":scope > li")
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
@@ -196,7 +238,7 @@ async function selectAssistantListItemFromMarker(
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(markerText, 0);
|
||||
range.setStart(element, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
@@ -211,7 +253,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.locator(tag)
|
||||
.filter({ hasText: text })
|
||||
.dblclick({ position: { x: 5, y: 5 } });
|
||||
}
|
||||
@@ -233,39 +275,23 @@ async function selectAssistantTextRange(
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
let endOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
if (!startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
startNode = textNode;
|
||||
startOffset = offset;
|
||||
}
|
||||
}
|
||||
if (startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
endNode = textNode;
|
||||
endOffset = offset + selectedRange.endText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
if (!startNode || !endNode) {
|
||||
const text = nodes.map((node) => node.data).join("");
|
||||
const start = text.indexOf(selectedRange.startText);
|
||||
const end = text.indexOf(selectedRange.endText, start);
|
||||
if (start < 0 || end < 0)
|
||||
throw new Error(
|
||||
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
}
|
||||
const boundary = (offset: number, isEnd = false): [Text, number] => {
|
||||
for (const node of nodes) {
|
||||
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
|
||||
offset -= node.length;
|
||||
}
|
||||
throw new Error("Selection extends beyond message");
|
||||
};
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
range.setStart(...boundary(start));
|
||||
range.setEnd(...boundary(end + selectedRange.endText.length, true));
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
@@ -385,16 +411,12 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
["em", "emphasized prose"],
|
||||
["s", "struck prose"],
|
||||
]) {
|
||||
const formattedProse = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text });
|
||||
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
|
||||
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
||||
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
||||
}
|
||||
const inlineCode = assistantMessage
|
||||
.locator('[data-paseo-markdown-tag="code"]')
|
||||
.filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveAttribute("data-pmono", "");
|
||||
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
|
||||
|
||||
await selectStructuredClipboardFixture(page);
|
||||
await copySelection(page);
|
||||
@@ -423,7 +445,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
||||
expect(clipboard.html).not.toContain(
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
||||
);
|
||||
expect(clipboard.html).toContain('<code class="language-typescript">');
|
||||
@@ -649,3 +671,55 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-image-",
|
||||
title: "Assistant selection copy image",
|
||||
initialPrompt: "Render the image fixture.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"Before the image.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"After the image.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await allowRichClipboard(context);
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
||||
|
||||
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
`Before the image.\n\n\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
|
||||
await dragFromBelowImageTo(page, "After the");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe("After the");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,9 +89,11 @@ async function dismissStackedModelPickerWithBackdrop(page: Page) {
|
||||
.getByTestId("agent-controls-settings-list")
|
||||
.getByRole("button", { name: /Select model/ })
|
||||
.click();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeInViewport();
|
||||
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
// In CI, Gorhom retains the dismissed sheet below the viewport.
|
||||
// Dismissal is complete when it leaves the displayed surface.
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
}
|
||||
@@ -139,7 +141,7 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectEmptyChanges(page: Page) {
|
||||
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editReadme(repoPath: string, text: string) {
|
||||
await appendFile(path.join(repoPath, "README.md"), text);
|
||||
}
|
||||
|
||||
async function expectReadmeChanges(page: Page, additions: number) {
|
||||
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(changesTree(page)).toContainText(`+${additions}`);
|
||||
}
|
||||
|
||||
async function openEmptyDiff(page: Page) {
|
||||
await changesTree(page).getByTestId("changes-options-menu").click();
|
||||
await page.getByTestId("changes-open-tab").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectReadmeDiff(page: Page, additions: number) {
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openDiffFromNewTabMenu(page: Page) {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
|
||||
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshDiff(page: Page) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("changes-refresh")
|
||||
.click();
|
||||
}
|
||||
|
||||
async function reloadWorkspace(page: Page) {
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("Changes follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeChanges(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeChanges(page, 2);
|
||||
});
|
||||
|
||||
test("the full diff follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
await openEmptyDiff(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
});
|
||||
|
||||
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openDiffFromNewTabMenu(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
|
||||
await refreshDiff(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await reloadWorkspace(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await editReadme(workspace.repoPath, "third edit\n");
|
||||
await expectReadmeDiff(page, 3);
|
||||
});
|
||||
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
|
||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
|
||||
@@ -33,9 +33,10 @@ const STREAMED_RESPONSE = [
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
// One message taller than the viewport, exercising navigation between distant
|
||||
// matches inside the same rendered row.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const VIRTUALIZED_TURN_COUNT = 60;
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
@@ -322,11 +323,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
// The only hit is in the first paragraph, which is no longer the row that grows.
|
||||
// The first-paragraph hit must remain searchable while the same message grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.replyFinished();
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two Markdown blocks, one count across both.
|
||||
// One message, two paragraphs, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
await expectNextMatch(page, "2 of 2", "pair-needle");
|
||||
await closeFind(page);
|
||||
@@ -346,12 +347,14 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
for (let index = 0; index < VIRTUALIZED_TURN_COUNT; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Prompt number ${VIRTUALIZED_TURN_COUNT - 1}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
return agent;
|
||||
} catch (error) {
|
||||
@@ -360,9 +363,8 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
|
||||
}
|
||||
}
|
||||
|
||||
// Every block row of the selected message must be mounted for Find to see it, whatever
|
||||
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
|
||||
// from the count and Next walks off to the next message instead.
|
||||
// Find must count distant paragraphs in one source message as well as messages
|
||||
// outside the mounted history window. Next must not skip a same-message hit.
|
||||
test("counts every occurrence of a virtualized message and steps between them", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -370,9 +372,9 @@ test("counts every occurrence of a virtualized message and steps between them",
|
||||
const chat = await openChatWithVirtualizedTallMessage(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
await expectFindStatus(page, "span-needle", "1 of 12");
|
||||
await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
|
||||
await expectHighlight(page, "span-needle");
|
||||
await expectNextMatch(page, "2 of 12", "span-needle");
|
||||
await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
attachPromptTimelineEvidence,
|
||||
expectPromptsDrawnByPlugin,
|
||||
expectVirtualizedPluginPromptVisible,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
|
||||
|
||||
// Unloaded, then loaded, then near the newest edge.
|
||||
await clickChatOutlineRowEdge(page, 40);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
|
||||
await attachPromptTimelineEvidence(page, test.info());
|
||||
await clickChatOutlineRowEdge(page, 45);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
|
||||
await clickChatOutlineRowEdge(page, 78);
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
const prompt = agent.prompts[29]!;
|
||||
try {
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptNotMounted(page, prompt);
|
||||
const requestsBeforeJump = history.requestCount();
|
||||
|
||||
await clickChatOutlineRowEdge(page, 30);
|
||||
await expectTimelinePromptLandedBelowTop(page, prompt);
|
||||
await expectVirtualizedPluginPromptVisible(page, prompt);
|
||||
await attachPromptTimelineEvidence(page, test.info());
|
||||
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||
}, [prompt]);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("with the whole conversation loaded", () => {
|
||||
let agent: LongTimelineAgent;
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import {
|
||||
closeCompactWorkspaceMenu,
|
||||
goBackInMenu,
|
||||
menuRow,
|
||||
openCompactWorkspaceMenu,
|
||||
openRunningScriptPage,
|
||||
openScriptsPage,
|
||||
runScriptFromScriptsPage,
|
||||
scriptRow,
|
||||
scriptsMenuRow,
|
||||
} from "../support/helpers/compact-workspace-scripts";
|
||||
|
||||
const labels = {
|
||||
long: {
|
||||
title: "workspace-with-a-very-long-release-and-investigation-name",
|
||||
project: "project-with-a-very-long-customer-integration-name",
|
||||
},
|
||||
short: { title: "main", project: "Paseo" },
|
||||
};
|
||||
|
||||
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
|
||||
try {
|
||||
await workspace.client.renameProject(workspace.projectId, names.project);
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Header layout check",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
|
||||
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
|
||||
await expect(
|
||||
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
|
||||
const menu = page.getByTestId("menu-button").filter({ visible: true });
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const project = page.getByTestId("workspace-header-subtitle");
|
||||
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
|
||||
const trigger = page.getByRole("button", { name: /Switch tabs/ });
|
||||
const measure = async () => ({
|
||||
menu: await menu.boundingBox(),
|
||||
title: await title.boundingBox(),
|
||||
project: await project.boundingBox(),
|
||||
badge: await badge.boundingBox(),
|
||||
icon: await badge.locator("svg").boundingBox(),
|
||||
trigger: await trigger.boundingBox(),
|
||||
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
|
||||
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
|
||||
});
|
||||
await page.screenshot({ path: info.outputPath("header.png") });
|
||||
await expect(async () => {
|
||||
const bounds = await measure();
|
||||
expect(bounds.menu).not.toBeNull();
|
||||
expect(bounds.title).not.toBeNull();
|
||||
expect(bounds.project).not.toBeNull();
|
||||
expect(bounds.badge).not.toBeNull();
|
||||
expect(bounds.icon).not.toBeNull();
|
||||
expect(bounds.trigger).not.toBeNull();
|
||||
// The labels sit between the menu button and the actions without overlapping either.
|
||||
expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
|
||||
expect(bounds.project!.x).toBe(bounds.title!.x);
|
||||
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
|
||||
expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
|
||||
}
|
||||
expect(bounds.project!.width).toBeGreaterThan(20);
|
||||
// Long labels truncate instead of squeezing the host icon.
|
||||
expect(bounds.icon!.width).toBe(bounds.icon!.height);
|
||||
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
|
||||
expect(bounds.titleTruncated).toBe(long);
|
||||
expect(bounds.projectTruncated).toBe(long);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
await info.attach("header-bounds", {
|
||||
body: JSON.stringify(await measure(), null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [320, 390]) {
|
||||
for (const kind of ["long", "short"] as const) {
|
||||
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
const workspace = await openNamedWorkspace(page, labels[kind]);
|
||||
try {
|
||||
await expectHeaderContentsFit(page, info, kind === "long");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
|
||||
const WEB_SERVER =
|
||||
"node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
|
||||
|
||||
async function openWorkspaceWithScripts(page: Page) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "compact-scripts-",
|
||||
repo: {
|
||||
paseoConfig: {
|
||||
scripts: {
|
||||
lint: { type: "script", command: KEEP_ALIVE },
|
||||
web: { type: "service", command: WEB_SERVER },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Scripts menu check",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await openWorkspaceWithScripts(page);
|
||||
try {
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("nothing running: the Scripts row has no value", async () => {
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
|
||||
await runScriptFromScriptsPage(page, "lint");
|
||||
await expectOpenTabCount(page, 2);
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("two running scripts are counted on the root row", async () => {
|
||||
await runScriptFromScriptsPage(page, "web");
|
||||
await expectOpenTabCount(page, 3);
|
||||
await openCompactWorkspaceMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("stopping a script keeps its page open in the stopped state", async () => {
|
||||
await openRunningScriptPage(page, "web");
|
||||
await expect(menuRow(page, "View service")).toBeVisible();
|
||||
await menuRow(page, "Stop").click();
|
||||
await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(menuRow(page, "Stop")).toHaveCount(0);
|
||||
|
||||
await goBackInMenu(page);
|
||||
await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
|
||||
await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
|
||||
await closeCompactWorkspaceMenu(page);
|
||||
});
|
||||
|
||||
await test.step("the root row names the one script still running", async () => {
|
||||
await openScriptsPage(page);
|
||||
await goBackInMenu(page);
|
||||
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectSubagentRowVisible,
|
||||
openSubagentFromCompactTrack,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import {
|
||||
closeSheet,
|
||||
expectOpenTabCount,
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectSheetAtSnap,
|
||||
replaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
sheet,
|
||||
sheetHeader,
|
||||
tapSheetBackdrop,
|
||||
} from "../support/helpers/sheet";
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-workspace-sheet-",
|
||||
title: "Sheet parent",
|
||||
initialPrompt: promptMentioning(FILE_LINK),
|
||||
});
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const linkedPath = path.join(workspace.cwd, "linked.ts");
|
||||
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("a chat file link opens the file in a sheet", async () => {
|
||||
const fileLink = page.getByText(FILE_LINK, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
const fileSheet = sheet(page, "linked.ts");
|
||||
await expect(fileSheet).toBeVisible();
|
||||
await expect(fileSheet.getByText("three")).toBeVisible();
|
||||
await expectSheetAtSnap(page, "linked.ts");
|
||||
await expect(
|
||||
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
|
||||
).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await closeSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
|
||||
// seeded agent has no context usage yet.
|
||||
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await openSheetAsTab(page, child.title);
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("the list the subagent was opened from closed with its tab", async () => {
|
||||
await selectTabFromSwitcher(page, "Sheet parent");
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
|
||||
const menuBase = `workspace-tab-menu-agent_${child.id}`;
|
||||
await page.getByTestId(`${menuBase}-trigger`).click();
|
||||
await page.getByTestId(`${menuBase}-close`).click();
|
||||
await expectOpenTabCount(page, 1);
|
||||
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
|
||||
// tapped once the row menu's own sheet has gone.
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||
const fileSheet = sheet(page, "linked.ts");
|
||||
await expect(fileSheet).toBeVisible();
|
||||
// Hold the autosave so the edit stays unsaved.
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
|
||||
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () =>
|
||||
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
|
||||
);
|
||||
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
@@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
// The sidebar covers the composer, so the composer gives up its popover while the
|
||||
// sidebar is open and brings it back once the sidebar closes.
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { 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 { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
attachFileFromMenu,
|
||||
} from "../support/helpers/composer";
|
||||
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
async function seedChangedAgent(
|
||||
repoPrefix: string,
|
||||
featureValues?: Record<string, unknown>,
|
||||
files: Array<{ path: string; content: string }> = [],
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
featureValues,
|
||||
title: "Composer diff stat",
|
||||
repo: {
|
||||
withRemote: true,
|
||||
// Exclude the local remote before the daemon starts observing this checkout.
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function tapFirstAddedDiffLine(
|
||||
page: Page,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
const body = surface.getByTestId("diff-file-0-body");
|
||||
await expect(body).toBeVisible({ timeout: 30000 });
|
||||
const bounds = await body.boundingBox();
|
||||
if (!bounds) throw new Error("Expanded diff body has no bounds");
|
||||
const fontSize = await surface
|
||||
.getByTestId("git-diff-canvas")
|
||||
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
|
||||
await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
|
||||
}
|
||||
|
||||
async function openReviewDiff(page: Page, workspace: Awaited<ReturnType<typeof seedChangedAgent>>) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
|
||||
if (await page.getByTestId("workspace-tab-working_diff").count()) {
|
||||
await selectDiffTab(page);
|
||||
} else {
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
}
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
|
||||
async function saveReviewComment(page: Page, body: string) {
|
||||
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||
await page.getByRole("button", { name: "Save review comment", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function cancelReviewComment(page: Page) {
|
||||
await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("review comments", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("compact-review-comment-");
|
||||
try {
|
||||
await test.step("Open a compact sheet with reachable input and actions", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await expectCompactCommentSheet(page);
|
||||
});
|
||||
await test.step("Save, discard an edit, then save an updated comment", async () => {
|
||||
await saveReviewComment(page, " Please simplify this. ");
|
||||
await expectSavedReview(page, "Please simplify this.");
|
||||
await editSavedReview(page, "Please simplify this.");
|
||||
await fillReviewComment(page, "Discard this edit");
|
||||
await cancelReviewComment(page);
|
||||
await expectSavedReview(page, "Please simplify this.");
|
||||
await editSavedReview(page, "Please simplify this.");
|
||||
await saveReviewComment(page, "Updated comment");
|
||||
await expectSavedReview(page, "Updated comment");
|
||||
});
|
||||
await test.step("Discard a new comment and retain the saved one after reload", async () => {
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await fillReviewComment(page, "Discard new comment");
|
||||
await cancelReviewComment(page);
|
||||
await expectReviewAbsent(page, "Discard new comment");
|
||||
await reloadReview(page);
|
||||
await expectSavedReview(page, "Updated comment");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("wide review comments retain inline editing", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("wide-review-comment-");
|
||||
try {
|
||||
await test.step("Save and cancel an inline edit on a wide screen", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await expectInlineCommentEditor(page);
|
||||
await saveReviewComment(page, "Wide comment");
|
||||
await expectSavedReview(page, "Wide comment");
|
||||
await editSavedReview(page, "Wide comment");
|
||||
await expectInlineCommentEditor(page);
|
||||
await cancelReviewComment(page);
|
||||
await expectSavedReview(page, "Wide comment");
|
||||
});
|
||||
await test.step("The existing composer still sends the review", async () => {
|
||||
await sendReviewThroughComposer(page, workspace.agentId);
|
||||
await selectDiffTab(page);
|
||||
await expectReviewAbsent(page, "Wide comment");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function sendReviewThroughComposer(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function addSavedReview(
|
||||
page: Page,
|
||||
body: string,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
await tapFirstAddedDiffLine(page, surface);
|
||||
await saveReviewComment(page, body);
|
||||
await expectSavedReview(page, body, surface);
|
||||
}
|
||||
|
||||
async function expectSavedReview(
|
||||
page: Page,
|
||||
body: string,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
await expect(surface.getByText(body, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editSavedReview(page: Page, expectedBody: string) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.getByRole("button", { name: "Edit review comment", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
|
||||
}
|
||||
|
||||
async function sendFeedback(page: Page, count: number) {
|
||||
await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectFeedbackSent(page: Page, recipient: string) {
|
||||
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("send review feedback", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-single-");
|
||||
try {
|
||||
await test.step("Save a review without changing the chat draft", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await fillComposerDraft(page, "Keep this thought");
|
||||
await attachFileFromMenu(page, {
|
||||
name: "unrelated.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Unrelated draft attachment"),
|
||||
});
|
||||
await selectDiffTab(page);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Please simplify this.");
|
||||
});
|
||||
await test.step("Send directly and stay on the diff", async () => {
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Please simplify this.");
|
||||
await captureFeedback(page, "single");
|
||||
});
|
||||
await test.step("Return to the untouched composer draft", async () => {
|
||||
await showWideLayout(page);
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await expectComposerDraft(page, "Keep this thought");
|
||||
await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-shared-pending-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Send feedback from the Diff tab and hold its real request", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "One draft across surfaces");
|
||||
await sendFeedback(page, 1);
|
||||
await gate.waitForRequest();
|
||||
});
|
||||
await test.step("The compact Changes surface observes the same pending operation", async () => {
|
||||
await revealCompactChanges(page);
|
||||
await expect(
|
||||
compactReviewSurface(page).getByRole("button", {
|
||||
name: "Sending feedback (1)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
expect(gate.requestCount()).toBe(1);
|
||||
gate.accept();
|
||||
await expect(
|
||||
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-editor-ownership-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await fillReviewComment(page, "Body across presentations");
|
||||
await showCompactLayout(page);
|
||||
await expectReviewEditorBody(page, "Body across presentations");
|
||||
await showWideLayout(page);
|
||||
await expectReviewEditorBody(page, "Body across presentations");
|
||||
await saveReviewComment(page, "Body across presentations");
|
||||
});
|
||||
await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
|
||||
await sendFeedback(page, 1);
|
||||
await gate.waitForRequest();
|
||||
await editSavedReview(page, "Body across presentations");
|
||||
await fillReviewComment(page, "Keep this unsaved edit");
|
||||
gate.accept();
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||
await showCompactLayout(page);
|
||||
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||
await saveReviewComment(page, "Keep this unsaved edit");
|
||||
await expectSavedReview(page, "Keep this unsaved edit");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
|
||||
const workspace = await seedMultiFileReview();
|
||||
try {
|
||||
await test.step("Save compact feedback and retain both floating actions", async () => {
|
||||
await openCompactReviewExplorer(page, workspace);
|
||||
await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
|
||||
await expectReviewNavigationReachable(page);
|
||||
});
|
||||
await test.step("Jump to another file without covering the feedback action", async () => {
|
||||
await jumpToReviewFile(page, "z-last.ts");
|
||||
await scrollReviewToLastLine(page);
|
||||
await expectReviewNavigationReachable(page);
|
||||
await captureFeedback(page, "navigation-compact");
|
||||
});
|
||||
await test.step("Wide file navigation and feedback remain reachable", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await navigateWideReviewFile(page, "README.md");
|
||||
await expectFeedbackReachable(page);
|
||||
await expectSavedReview(page, "Keep file navigation available");
|
||||
await captureFeedback(page, "navigation-wide");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("feedback sends only matching comments and preserves orphaned comments", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMultiFileReview();
|
||||
try {
|
||||
await test.step("A saved comment whose diff disappears has no sendable context", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Retain this orphan");
|
||||
await setReadmeDiff(workspace, false);
|
||||
await expectUnmatchedFeedback(page);
|
||||
});
|
||||
await test.step("Send the matching subset with an accurate count", async () => {
|
||||
await addSavedReview(page, "Send this matching comment");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Send this matching comment");
|
||||
await expectUnmatchedFeedback(page);
|
||||
});
|
||||
await test.step("Restoring the missing diff reveals the unsent comment", async () => {
|
||||
await setReadmeDiff(workspace, true);
|
||||
await expectSavedReview(page, "Retain this orphan");
|
||||
await expectFeedbackReachable(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-zero-");
|
||||
try {
|
||||
await test.step("Close the agent tab while keeping the review", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await addSavedReview(page, "Keep until an agent is opened");
|
||||
await closeAgentTab(page, workspace.agentId);
|
||||
});
|
||||
await test.step("The disabled action explains why in wide and compact layouts", async () => {
|
||||
await expectFeedbackUnavailable(page);
|
||||
await captureFeedback(page, "zero-wide");
|
||||
await showCompactLayout(page);
|
||||
await expectFeedbackUnavailable(page);
|
||||
await expectSavedReview(page, "Keep until an agent is opened");
|
||||
await captureFeedback(page, "zero-compact");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||
const other = await seedChangedAgent("review-feedback-other-");
|
||||
try {
|
||||
const second = await createReviewRecipient(workspace, "Second reviewer");
|
||||
const historical = await createReviewRecipient(workspace, "Closed historical agent");
|
||||
await test.step("Open two local tabs and a tab in another workspace", async () => {
|
||||
await openReviewDiff(page, other);
|
||||
await addSavedReview(page, "Other workspace review");
|
||||
await openReviewDiff(page, workspace);
|
||||
await closeAgentTab(page, historical.id);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
|
||||
await selectDiffTab(page);
|
||||
await addSavedReview(page, "Review for second agent");
|
||||
});
|
||||
await test.step("The wide menu includes only local open agents", async () => {
|
||||
await captureWideFeedbackAppearance(page);
|
||||
});
|
||||
await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
|
||||
await openFeedbackWithKeyboard(page);
|
||||
await dismissRecipientMenu(page);
|
||||
await expectFeedbackFocused(page);
|
||||
await reopenFeedbackWithKeyboard(page);
|
||||
await dismissRecipientMenu(page);
|
||||
await expectFeedbackFocused(page);
|
||||
});
|
||||
await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
|
||||
await showCompactLayout(page);
|
||||
await captureFeedback(page, "cta-compact");
|
||||
await sendFeedback(page, 1);
|
||||
await expectRecipientMenu(page);
|
||||
await captureFeedback(page, "menu-compact");
|
||||
await chooseFeedbackRecipient(page, "Second reviewer");
|
||||
await expectFeedbackSent(page, "Second reviewer");
|
||||
await expectReviewAbsent(page, "Review for second agent");
|
||||
});
|
||||
await test.step("The other workspace's saved review remains", async () => {
|
||||
await openReviewDiff(page, other);
|
||||
await expectSavedReview(page, "Other workspace review");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed feedback remains editable and retries through the real provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
|
||||
try {
|
||||
await test.step("A provider rejection retains the saved review", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Initial feedback");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackFailure(page);
|
||||
await expectSavedReview(page, "Initial feedback");
|
||||
await captureFeedback(page, "failure");
|
||||
});
|
||||
await test.step("Edit the retained feedback and retry successfully", async () => {
|
||||
await editSavedReview(page, "Initial feedback");
|
||||
await saveReviewComment(page, "Corrected feedback");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Corrected feedback");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending feedback blocks repeat submission and preserves edits and additions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-pending-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Hold the real request while feedback is pending", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Sent version");
|
||||
await sendFeedback(page, 1);
|
||||
const request = await gate.waitForRequest();
|
||||
expect(request.agentId).toBe(workspace.agentId);
|
||||
expect(request.text).toBe("Please address this code review.");
|
||||
expect(request.attachments).toMatchObject([
|
||||
{
|
||||
type: "review",
|
||||
cwd: workspace.cwd,
|
||||
comments: [
|
||||
{
|
||||
filePath: "README.md",
|
||||
lineNumber: 2,
|
||||
body: "Sent version",
|
||||
context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
await expectFeedbackPending(page);
|
||||
});
|
||||
await test.step("Save an edit and another comment before acknowledgement", async () => {
|
||||
await editSavedReview(page, "Sent version");
|
||||
await saveReviewComment(page, "Edited during send");
|
||||
await addSavedReview(page, "Added during send");
|
||||
await expectFeedbackPending(page);
|
||||
expect(gate.requestCount()).toBe(1);
|
||||
await captureFeedback(page, "pending");
|
||||
});
|
||||
await test.step("A real acknowledgement preserves both new versions", async () => {
|
||||
gate.accept();
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectSavedReview(page, "Edited during send");
|
||||
await expectSavedReview(page, "Added during send");
|
||||
await expectReviewAbsent(page, "Sent version");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function showCompactLayout(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
async function showWideLayout(page: Page) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
}
|
||||
async function selectAgentTab(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
async function selectDiffTab(page: Page) {
|
||||
await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
async function fillReviewComment(page: Page, body: string) {
|
||||
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||
}
|
||||
async function expectReviewAbsent(page: Page, body: string) {
|
||||
await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
async function reloadReview(page: Page) {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
async function expectInlineCommentEditor(page: Page) {
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
|
||||
}
|
||||
async function expectCompactCommentSheet(page: Page) {
|
||||
await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
|
||||
await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
|
||||
for (const action of ["Save", "Cancel"])
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${action} review comment`, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Save review comment", exact: true }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
function feedbackButton(page: Page) {
|
||||
return page.getByRole("button", { name: "Send feedback (1)", exact: true });
|
||||
}
|
||||
async function openFeedbackWithKeyboard(page: Page) {
|
||||
await feedbackButton(page).focus();
|
||||
await expectFeedbackFocused(page);
|
||||
await reopenFeedbackWithKeyboard(page);
|
||||
}
|
||||
async function reopenFeedbackWithKeyboard(page: Page) {
|
||||
await page.keyboard.press("Enter");
|
||||
await expectRecipientMenu(page);
|
||||
const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
|
||||
await recipient.focus();
|
||||
await expect(recipient).toBeFocused();
|
||||
}
|
||||
async function expectFeedbackFocused(page: Page) {
|
||||
await expect(feedbackButton(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function captureWideFeedbackAppearance(page: Page) {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expectSavedReview(page, "Review for second agent");
|
||||
await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
|
||||
await sendFeedback(page, 1);
|
||||
await expectRecipientMenu(page);
|
||||
await captureFeedback(page, `cta-wide-${colorScheme}-open`);
|
||||
await dismissRecipientMenu(page);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
async function captureFeedback(page: Page, name: string) {
|
||||
await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
|
||||
}
|
||||
|
||||
async function closeAgentTab(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
|
||||
}
|
||||
async function expectFeedbackUnavailable(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function createReviewRecipient(
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
title: string,
|
||||
) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
}
|
||||
async function expectRecipientMenu(page: Page) {
|
||||
for (const name of ["Composer diff stat", "Second reviewer"]) {
|
||||
await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(2);
|
||||
}
|
||||
async function dismissRecipientMenu(page: Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||
}
|
||||
async function chooseFeedbackRecipient(page: Page, name: string) {
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
}
|
||||
async function expectFeedbackFailure(page: Page) {
|
||||
await expect(
|
||||
page.getByText("Requested mock prompt rejection", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
|
||||
}
|
||||
async function expectFeedbackPending(page: Page, count = 1) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
|
||||
async function seedMultiFileReview() {
|
||||
const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
|
||||
{ path: "z-last.ts", content: "export const final = 0;\n" },
|
||||
]);
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "z-last.ts"),
|
||||
Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
|
||||
"\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
return workspace;
|
||||
}
|
||||
async function expectFeedbackReachable(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
|
||||
{ ratio: 1 },
|
||||
);
|
||||
}
|
||||
async function expectReviewNavigationReachable(page: Page) {
|
||||
await expectFeedbackReachable(page);
|
||||
const jump = page.getByRole("button", { name: "Jump to file", exact: true });
|
||||
await expect(jump).toBeInViewport({ ratio: 1 });
|
||||
const navigationBounds = await jump.boundingBox();
|
||||
const feedbackBounds = await page
|
||||
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||
.boundingBox();
|
||||
if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
|
||||
expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
|
||||
}
|
||||
async function jumpToReviewFile(page: Page, name: string) {
|
||||
await page.getByRole("button", { name: "Jump to file", exact: true }).click();
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
await page
|
||||
.getByTestId("changes-file-tree")
|
||||
.filter({ visible: true })
|
||||
.getByText(name, { exact: true })
|
||||
.click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
|
||||
}
|
||||
async function navigateWideReviewFile(page: Page, name: string) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
|
||||
await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
|
||||
}
|
||||
|
||||
async function setReadmeDiff(
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
changed: boolean,
|
||||
) {
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
}
|
||||
async function expectUnmatchedFeedback(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Saved comments no longer match this diff.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
function compactReviewSurface(page: Page) {
|
||||
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
}
|
||||
async function openCompactReviewExplorer(
|
||||
page: Page,
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
) {
|
||||
await showWideLayout(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await showCompactLayout(page);
|
||||
await composerChangesPill(page).click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
|
||||
function reviewFileHeader(surface: Locator, name: string) {
|
||||
return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
|
||||
}
|
||||
|
||||
async function scrollReviewToLastLine(page: Page) {
|
||||
const surface = compactReviewSurface(page);
|
||||
const bounds = await surface.boundingBox();
|
||||
if (!bounds) throw new Error("Compact review has no bounds");
|
||||
await page.mouse.move(bounds.x + 120, bounds.y + 200);
|
||||
await page.mouse.wheel(0, 10000);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await surface.getByTestId("diff-file-1-body").boundingBox();
|
||||
const feedback = await page
|
||||
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||
.boundingBox();
|
||||
if (!body || !feedback) return false;
|
||||
return body.y + body.height <= feedback.y;
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function revealCompactChanges(page: Page) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
selectComposerDraftRange,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
return page.getByTestId("composer-autocomplete-popover");
|
||||
}
|
||||
|
||||
async function selectReadmeMention(page: Page): Promise<void> {
|
||||
await fillComposerDraft(page, "@README");
|
||||
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
|
||||
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, '"README.md"');
|
||||
}
|
||||
|
||||
async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
await fillComposerDraft(page, email);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, "");
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("complete a workspace file mention with Enter", async () => {
|
||||
await selectReadmeMention(page);
|
||||
});
|
||||
await test.step("send an email without offering or inserting workspace files", async () => {
|
||||
await sendEmailIntact(page, email);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-mention-",
|
||||
title: "Fullscreen mention keys",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const draft = "First line\nSecond line\n@README";
|
||||
await fillComposerDraft(page, draft);
|
||||
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await selectComposerDraftRange(page, draft.length, draft.length);
|
||||
await composerLocator(page).press("ArrowUp");
|
||||
await expect
|
||||
.poll(() =>
|
||||
composerLocator(page).evaluate((element) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await selectComposerDraftRange(page, draft.length, draft.length);
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, `${draft}\n`);
|
||||
await exitComposerFullscreen(page);
|
||||
await selectReadmeMention(page);
|
||||
const commandDraft = "/cl\nSecond line\nThird line";
|
||||
await fillComposerDraft(page, commandDraft);
|
||||
await selectComposerDraftRange(page, 2, 2);
|
||||
await composerLocator(page).press("ArrowRight");
|
||||
await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,127 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerFullscreenControls,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
rememberTimelinePromptPosition,
|
||||
seedLongMockAgentTimeline,
|
||||
waitForTimelineGeometryToSettle,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
test("clicking chat text or background focuses the feed for keyboard paging", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 8 });
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
const composer = composerLocator(page);
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await composer.fill("Keep this draft");
|
||||
await feed.getByText(agent.newestPrompt, { exact: true }).click();
|
||||
await expect(feed).toBeFocused();
|
||||
|
||||
const beforePaging = await readScrollMetrics(page);
|
||||
expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
await composer.click();
|
||||
await expectComposerFocused(page);
|
||||
await feed.click({ position: { x: 2, y: 40 } });
|
||||
await expect(feed).toBeFocused();
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeGreaterThan(beforePaging.viewportHeight / 2);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const readingPrompt = agent.prompts[4];
|
||||
await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
|
||||
const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
// The daemon finishing does not mean its response has reached the browser.
|
||||
await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await expectTimelinePromptPositionPreserved(page, readingPosition);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("focusing the feed preserves assistant text selection and code copy controls", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sentence = "Select this assistant sentence.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-focus-selection-",
|
||||
title: "Chat focus selection",
|
||||
initialPrompt: "Render text and code.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await composerLocator(page).fill("Keep this draft");
|
||||
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
|
||||
await expect(feed).toBeFocused();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
|
||||
|
||||
const code = feed.locator('[data-paseo-markdown-language="bash"]');
|
||||
await code.hover();
|
||||
await code.locator("[data-paseo-markdown-ignore]").click();
|
||||
await expect(feed).not.toBeFocused();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -56,6 +170,59 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-touch-remount-",
|
||||
title: "Touch remount",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await composerLocator(page).fill("Keep this draft through resize");
|
||||
await expectComposerFocused(page);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await expectComposerDraft(page, "Keep this draft through resize");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First touch chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await composerLocator(page).fill("Keep this touch draft");
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expectComposerDraft(page, "");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerDraft(page, "Keep this touch draft");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, "Keep this touch draft after return");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
@@ -76,3 +243,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const COMPACT_PANELS = [
|
||||
{
|
||||
name: "agent list",
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
},
|
||||
{
|
||||
name: "Explorer",
|
||||
open: swipeOpenMobileExplorer,
|
||||
expectOpen: expectMobileExplorerVisible,
|
||||
close: closeMobileExplorer,
|
||||
expectClosed: expectMobileExplorerHidden,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const panel of COMPACT_PANELS) {
|
||||
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-panel-",
|
||||
title: `Composer focus under ${panel.name}`,
|
||||
});
|
||||
const draft = "Line one\nLine two\nLine three";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await test.step("an untouched editor stays untouched", async () => {
|
||||
await expectComposerNotFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
});
|
||||
|
||||
await test.step("an inline editor blurred under the panel stays blurred", async () => {
|
||||
await composerLocator(page).fill(draft);
|
||||
await composerLocator(page).click();
|
||||
await expectComposerFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await blurComposerInput(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
await expectComposerDraft(page, draft);
|
||||
});
|
||||
|
||||
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
|
||||
await composerLocator(page).click();
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await blurComposerInput(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraft(page, draft);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectNearBottom,
|
||||
@@ -5,8 +7,38 @@ import {
|
||||
waitForScrollableChat,
|
||||
} from "../support/helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
attachComposerTestImage,
|
||||
pasteComposerTestImage,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
selectComposerDraftRange,
|
||||
expectComposerDraftSelection,
|
||||
expectComposerFullscreenControls,
|
||||
sendComposerWithButton,
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
switchComposerTabWithKeyboard,
|
||||
expectWorkspaceHeaderHidden,
|
||||
expectWorkspaceHeaderVisible,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
expectComposerFullscreenFitsVisibleViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
||||
@@ -133,3 +165,471 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
|
||||
async function pausePageClock(page: Page): Promise<void> {
|
||||
const now = Date.now();
|
||||
await page.clock.install({ time: now });
|
||||
// The installed clock advances between protocol calls; pause at a future time.
|
||||
await page.clock.pauseAt(now + 60_000);
|
||||
}
|
||||
|
||||
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
|
||||
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
const alertBox = (await frame.getByRole("alert").boundingBox())!;
|
||||
const sendBox = (await page
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.boundingBox())!;
|
||||
const editorBox = (await composerLocator(page).boundingBox())!;
|
||||
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
|
||||
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
|
||||
}
|
||||
|
||||
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
||||
await composerLocator(page).fill(text);
|
||||
await expect(composerLocator(page)).toHaveValue(text);
|
||||
}
|
||||
|
||||
test("compact long drafts stop growing before the header", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-cap-",
|
||||
title: "Composer cap",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "A long draft line\n".repeat(40));
|
||||
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-",
|
||||
title: "Fullscreen composer",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
|
||||
await attachComposerTestImage(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await writeCompactDraft(page, "First line\nSecond line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await writeCompactDraft(
|
||||
page,
|
||||
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
||||
4,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
||||
await selectComposerDraftRange(page, 3, 12);
|
||||
});
|
||||
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue(
|
||||
"Edited fullscreen draft\nSecond line\nThird line",
|
||||
);
|
||||
await expectComposerDraftSelection(page, 3, 12);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
});
|
||||
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Typed after the fullscreen cycle");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
});
|
||||
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Typed inside fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
|
||||
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await writeCompactDraft(page, "One more inline message");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-remount-caret-",
|
||||
title: "Remount caret",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await selectComposerDraftRange(page, 5, 5);
|
||||
// The draft owner publishes web edits after the next paint. A rotation inside that
|
||||
// window remounts the editor before the edit has reached the draft store; the paused
|
||||
// clock holds the page there instead of racing the frame.
|
||||
await pausePageClock(page);
|
||||
await composerLocator(page).press("X");
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await page.clock.resume();
|
||||
await expect(composerLocator(page)).toHaveValue("ShortX draft");
|
||||
await expectComposerDraftSelection(page, 6, 6);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await composerLocator(page).press("Y");
|
||||
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-retry-",
|
||||
title: "Fullscreen retry",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
const draft = "First line\nSecond line\nThird line";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await sendComposerWithButton(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectFullscreenErrorAboveSend(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function blurComposer(page: Page): Promise<void> {
|
||||
await composerLocator(page).blur();
|
||||
await expect(composerLocator(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
/** Mid-band at the given height and near the editor's bottom edge. */
|
||||
async function reservedBandPoints(
|
||||
page: Page,
|
||||
bandX: number,
|
||||
topY: number,
|
||||
): Promise<Array<{ x: number; y: number }>> {
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
return [
|
||||
{ x: bandX, y: topY },
|
||||
{ x: bandX, y: editor.y + editor.height - 6 },
|
||||
];
|
||||
}
|
||||
|
||||
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-band-focus-",
|
||||
title: "Composer band focus",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
|
||||
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const longDraft = "Band first line\nBand second line\nBand third line";
|
||||
await writeCompactDraft(page, longDraft);
|
||||
await expect(fullscreenButton).toBeVisible();
|
||||
const button = (await fullscreenButton.boundingBox())!;
|
||||
const bandX = button.x + button.width / 2;
|
||||
|
||||
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
|
||||
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("the button itself still toggles fullscreen", async () => {
|
||||
await fullscreenButton.click();
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
});
|
||||
|
||||
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
|
||||
await writeCompactDraft(page, "Short");
|
||||
await expect(fullscreenButton).toHaveCount(0);
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue("Short");
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await configureComposerQueueSend(page);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "composer-fullscreen-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for fullscreen queue test.",
|
||||
});
|
||||
try {
|
||||
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await queueComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expectQueuedMessageButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-paste-remount-",
|
||||
title: "Composer paste",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
|
||||
await enterComposerFullscreen(page);
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First retained chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second retained chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Live first line\nLive second line\nLive third line";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
|
||||
// a desktop-width window still has the tab shortcut.
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
|
||||
await composerLocator(page).press("Escape");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await writeCompactDraft(page, "Other tab draft");
|
||||
await shrinkVisibleViewport(page);
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-sidebar-",
|
||||
title: "Fullscreen under sidebar",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
const draft = "Line one\nLine two\nLine three";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First capacity chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second capacity chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Retained long line\n".repeat(40);
|
||||
await writeCompactDraft(page, draft);
|
||||
await recordVisibleComposerGeometry(page);
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
await writeCompactDraft(page, "Other visible draft");
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
|
||||
await expectNoCollapsedVisibleComposer(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function shrinkVisibleViewport(page: Page): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
async function panVisibleViewport(page: Page): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: 4, y: 300 }],
|
||||
});
|
||||
for (let y = 290; y >= 100; y -= 10) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: 4, y }],
|
||||
});
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-visible-viewport-",
|
||||
title: "Visible viewport composer",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await shrinkVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await panVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -268,10 +268,16 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
|
||||
const refreshed = gate();
|
||||
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
|
||||
// Past the card's close grace, with the pointer still on the card.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(card).toBeVisible();
|
||||
refreshed.open();
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
@@ -128,3 +128,46 @@ for (const scenario of [
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openAgentDraft(),
|
||||
button: "Send message",
|
||||
settled: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
|
||||
},
|
||||
{
|
||||
name: "new workspace",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openWorkspaceForm("local"),
|
||||
button: "Create",
|
||||
settled: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.expectOneCreatedWorkspace(),
|
||||
},
|
||||
]) {
|
||||
test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
|
||||
creation,
|
||||
promptRejection,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
void promptRejection;
|
||||
const prompt =
|
||||
"Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
|
||||
await entry.open(creation);
|
||||
await creation.submitPrompt(prompt, entry.button);
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
await creation.expectPromptVisible(
|
||||
"emit 1 coalesced agent stream updates for a corrected prompt.",
|
||||
);
|
||||
await creation.expectAssistantReply();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("button", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
await expect
|
||||
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -96,6 +105,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "percent-link-cache-",
|
||||
title: "Distinct file links",
|
||||
initialPrompt: "Show both filenames",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
|
||||
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
|
||||
await openAgentRoute(page, session);
|
||||
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
|
||||
for (const link of links) {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
if (link === "raw") {
|
||||
await openAssistantInlinePath(page, "literal%20.md");
|
||||
await expectFileTabOpen(page, "literal%20.md");
|
||||
await expectPreviewText(page, "Literal filename");
|
||||
} else {
|
||||
await openAssistantMarkdownLink(page, "Decoded");
|
||||
await expectFileTabOpen(page, "literal .md");
|
||||
await expectPreviewText(page, "Decoded filename");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
@@ -154,7 +221,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -626,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -757,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("link", { name, exact: true }).first().click();
|
||||
}
|
||||
|
||||
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
|
||||
}
|
||||
|
||||
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
|
||||
await page.getByText(token, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPreviewText(page: Page, text: string): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText(text, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings";
|
||||
|
||||
// Use an existing repository linked to the signed-in user's Hoplite project.
|
||||
const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../..");
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } },
|
||||
});
|
||||
|
||||
async function openHopliteSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise<void> {
|
||||
const models = await client.listProviderModels("hoplite");
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models).toEqual([]);
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({
|
||||
status: "ready",
|
||||
models: [],
|
||||
});
|
||||
await expect(page.getByText("No models detected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function refreshGlobalCatalog(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceModels(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerFocused(page);
|
||||
await openModelPicker(page);
|
||||
await page.getByTestId("sheet-header-back").click();
|
||||
await drillIntoProvider(page, "hoplite");
|
||||
}
|
||||
|
||||
test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({
|
||||
page,
|
||||
e2eWorkerClient,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "hoplite-discovery" });
|
||||
const created = await e2eWorkerClient.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "Hoplite discovery QA",
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const workspace = created.workspace!;
|
||||
try {
|
||||
await test.step("global settings succeed with no models, including refresh", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") });
|
||||
});
|
||||
|
||||
await test.step("the linked workspace discovers real models", async () => {
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
await testInfo.attach("workspace-models", {
|
||||
body: JSON.stringify(models, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await openWorkspaceModels(page, workspace.id);
|
||||
await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
|
||||
await test.step("workspace discovery does not populate the global catalog", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await e2eWorkerClient.removeProject(workspace.projectId);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await enterComposerFullscreen(page);
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await delay.waitForCreateRequest();
|
||||
|
||||
await test.step("submission returns inline with the original draft and spinner", async () => {
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await exitComposerFullscreen(page);
|
||||
});
|
||||
delay.release();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
} finally {
|
||||
delay.release();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
enterComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
expectComposerFullscreenControls,
|
||||
} from "../support/helpers/composer";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function startDictationWithShortcut(page: Page): Promise<void> {
|
||||
await composerLocator(page).press("Control+d");
|
||||
}
|
||||
|
||||
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "fullscreen-dictation-",
|
||||
title: "Fullscreen dictation shortcut",
|
||||
});
|
||||
await installSyntheticMicrophone(page);
|
||||
const harness = await installDictationFailureHarness(page);
|
||||
const draft = "First line\nSecond line\nThird line";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await fillComposerDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await startDictationWithShortcut(page);
|
||||
await harness.waitForAudio();
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Insert transcription", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await enterComposerFullscreen(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await composerLocator(page).press("Control+Shift+d");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerFullscreenControls,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalLaunchSelected,
|
||||
@@ -9,13 +20,12 @@ import {
|
||||
expectTerminalPreviewCommand,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
launchTrigger,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
terminalLaunchSubmit,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Someone who wants a terminal agent instead of a chat: they pick a profile
|
||||
@@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = {
|
||||
args: ["-c", "echo bare-launch-static-line; exec cat"],
|
||||
};
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
|
||||
});
|
||||
|
||||
test.describe("New workspace: launching a terminal", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
@@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
});
|
||||
|
||||
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
|
||||
const selectProfile = async (profile: TerminalProfile) => {
|
||||
await launchTrigger(page).click();
|
||||
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
};
|
||||
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
|
||||
await test.step("restore a multiline prompt after a read-only profile", async () => {
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await fillTerminalPrompt(page, prompt);
|
||||
await expect(fullscreen).toBeVisible();
|
||||
await selectProfile(BARE_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||
await expect(fullscreen).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
|
||||
await fillTerminalPrompt(page, "Short draft");
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
await selectProfile(BARE_PROFILE);
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
await terminalPromptInput(page).press("End");
|
||||
await terminalPromptInput(page).press("Shift+Enter");
|
||||
await terminalPromptInput(page).pressSequentially("Second");
|
||||
await terminalPromptInput(page).press("Shift+Enter");
|
||||
await terminalPromptInput(page).pressSequentially("Third");
|
||||
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
|
||||
await expect(fullscreen).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
|
||||
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
|
||||
*/
|
||||
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
|
||||
await expect(frame.getByRole("button")).toHaveCount(2);
|
||||
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
|
||||
await expect
|
||||
.poll(() =>
|
||||
frame.evaluate(
|
||||
(element, point) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const box = element.getBoundingClientRect();
|
||||
const covering = document.elementFromPoint(point.x, point.y);
|
||||
return {
|
||||
fillsHeight:
|
||||
Math.abs(box.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(box.height - viewport.height) < 1,
|
||||
reachesRightEdge:
|
||||
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
|
||||
box.left >= viewport.offsetLeft,
|
||||
coversForm: element.contains(covering),
|
||||
};
|
||||
},
|
||||
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
|
||||
),
|
||||
)
|
||||
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
|
||||
}
|
||||
|
||||
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
|
||||
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Chat first line\nChat second line\nChat third line";
|
||||
await fillNewWorkspaceDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceFullscreen(page, "Create");
|
||||
// The desktop form's own import button is dropped from the fullscreen slot.
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await composerLocator(page).press("End");
|
||||
await composerLocator(page).pressSequentially(" wide");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
await launchTrigger(page).click();
|
||||
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
|
||||
await fillTerminalPrompt(page, prompt);
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(terminalPromptInput(page)).toBeVisible();
|
||||
await expectNewWorkspaceFullscreen(page, "Launch");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
try {
|
||||
await preparePairingHost(page, daemon);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
|
||||
await expectCompletePairingQr(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
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.
|
||||
// Literal shell commands and copyright markers must survive Markdown rendering.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -25,6 +19,7 @@ test.describe("Plan card markdown", () => {
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard.locator(".enrm-text")).toBeVisible();
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
@@ -35,3 +30,161 @@ test.describe("Plan card markdown", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("chat and file preview share the library renderer and resolve file links", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withSharedMarkdownFiles(page, async () => {
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message).toHaveCount(1);
|
||||
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
|
||||
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
|
||||
await expect(message.locator("blockquote")).toContainText("A quote");
|
||||
await expect(message.getByRole("table")).toContainText("42");
|
||||
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
|
||||
await expect(message.getByRole("cell").nth(1)).toHaveText("");
|
||||
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
|
||||
await expect(message.locator("p div")).toHaveCount(0);
|
||||
await openSharedMarkdownFile(page, "README.md");
|
||||
const preview = page.getByTestId("markdown-preview-frame");
|
||||
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
|
||||
await expect(preview.getByRole("table")).toContainText("42");
|
||||
await returnToSharedMarkdownChat(page);
|
||||
await expectFileLinkTooltip(
|
||||
page,
|
||||
message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
|
||||
"packages/app/src/说明.md:2-3",
|
||||
);
|
||||
await openSharedMarkdownFile(page, "src/说明.md:2-3");
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "Space file");
|
||||
await expect(
|
||||
page.getByText("Explicit link opened the space filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await openSharedMarkdownFile(page, "my%20file.ts");
|
||||
await expect(
|
||||
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("shared-renderer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
});
|
||||
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText(path);
|
||||
}
|
||||
|
||||
async function withSharedMarkdownFiles(page: Page, run: () => Promise<void>) {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const source =
|
||||
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "shared-markdown-",
|
||||
title: "Shared Markdown",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
initialPrompt: "Show Markdown",
|
||||
});
|
||||
try {
|
||||
await writeFile(`${session.cwd}/README.md`, source);
|
||||
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
|
||||
await writeFile(
|
||||
`${session.cwd}/my%20file.ts`,
|
||||
"Inline code opened the literal percent filename.",
|
||||
);
|
||||
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
|
||||
await writeFile(
|
||||
`${session.cwd}/packages/app/src/说明.md`,
|
||||
"# Nested file\n\nUnicode suffix lookup succeeded.",
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
// A cold render must work even when a separate parser chunk cannot be fetched.
|
||||
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
|
||||
await openAgentRoute(page, session);
|
||||
await run();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function returnToSharedMarkdownChat(page: Page) {
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openSharedMarkdownFile(page: Page, name: string) {
|
||||
await returnToSharedMarkdownChat(page);
|
||||
await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
test("linked inline code keeps code typography and wraps long paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const longPath =
|
||||
"packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-code-style-",
|
||||
title: "Inline code style",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
|
||||
longPath +
|
||||
"`.",
|
||||
},
|
||||
initialPrompt: "Show inline code",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
|
||||
const measurements = await measureInlineCode(message);
|
||||
await testInfo.attach("inline-code-measurements", {
|
||||
body: JSON.stringify(measurements, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(measurements.codes).toHaveLength(4);
|
||||
const [plain, file, url, long] = measurements.codes;
|
||||
for (const linked of [file, url, long]) {
|
||||
expect(linked.fontSize).toBe(plain.fontSize);
|
||||
expect(linked.fontFamily).toBe(plain.fontFamily);
|
||||
expect(linked.backgroundColor).toBe(plain.backgroundColor);
|
||||
expect(linked.color).toBe(linked.anchorColor);
|
||||
expect(linked.color).not.toBe(plain.color);
|
||||
}
|
||||
expect(long.lines).toBeGreaterThan(1);
|
||||
expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureInlineCode(message: Locator) {
|
||||
return message.evaluate((element) => {
|
||||
// Constrain the paragraph to exercise an inline path at a line boundary.
|
||||
const paragraph = element.querySelector("p")!;
|
||||
paragraph.style.width = "300px";
|
||||
const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
|
||||
const style = getComputedStyle(code);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(code);
|
||||
const rects = Array.from(range.getClientRects());
|
||||
return {
|
||||
text: code.textContent,
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
|
||||
whiteSpace: style.whiteSpace,
|
||||
overflowWrap: style.overflowWrap,
|
||||
rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
|
||||
lines: new Set(rects.map((rect) => rect.top)).size,
|
||||
right: Math.max(...rects.map((rect) => rect.right)),
|
||||
};
|
||||
});
|
||||
return { codes, right: paragraph.getBoundingClientRect().right };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Plugin source is neither an existing directory nor a Git URL",
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
const text = page.getByText(description, { exact: true });
|
||||
await expect(text).toBeVisible();
|
||||
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||
});
|
||||
|
||||
async function installPluginWithUntrustedDescription(
|
||||
environment: PluginEnvironment,
|
||||
description: string,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
environment.directory,
|
||||
"plain-text-plugin",
|
||||
description,
|
||||
"Plain text plugin",
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { composerLocator, submitMessageWithButton } from "../support/helpers/composer";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { withPopoverTimelinePlugin } from "../support/helpers/plugin-timeline";
|
||||
|
||||
async function openPreviewWhileTyping(page: Page): Promise<void> {
|
||||
await composerLocator(page).focus();
|
||||
await page.getByRole("button", { name: "Hover plugin preview", exact: true }).hover();
|
||||
await expect(page.getByRole("dialog", { name: "Plugin preview" })).toBeVisible();
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function dismissPreview(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Plugin preview" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("Escape dismisses a plugin preview before interrupting the agent", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plugin-popover-",
|
||||
title: "Plugin preview keyboard regression",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
try {
|
||||
await withPopoverTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessageWithButton(page, "Show the plugin preview.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expect(page.getByRole("button", { name: "Hover plugin preview" })).toBeVisible();
|
||||
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
|
||||
const stop = page.getByRole("button", { name: "Stop agent", exact: true });
|
||||
await expect(stop).toBeVisible();
|
||||
|
||||
await openPreviewWhileTyping(page);
|
||||
await dismissPreview(page);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(stop).not.toBeVisible();
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const test = base.extend<{ projectWithTrailingSpace: string }>({
|
||||
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
|
||||
try {
|
||||
const repoPath = path.join(root, "repo ");
|
||||
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.local",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoPath, stdio: "pipe" },
|
||||
);
|
||||
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
|
||||
const added = await client.addProject(repoPath);
|
||||
expect(added.error).toBeNull();
|
||||
const projectId = added.project!.projectId;
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath, projectId },
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
await provide(created.workspace!.id);
|
||||
} finally {
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"Windows does not preserve trailing spaces in folder names",
|
||||
);
|
||||
|
||||
test("Changes shows files in a project whose folder ends in a space", async ({
|
||||
page,
|
||||
projectWithTrailingSpace,
|
||||
}, testInfo) => {
|
||||
await gotoWorkspace(page, projectWithTrailingSpace);
|
||||
await openChangesTreePanel(page);
|
||||
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("changes.png") });
|
||||
});
|
||||
@@ -10,16 +10,29 @@ import {
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
const PLUGIN_PROVIDER = {
|
||||
id: "direct-example",
|
||||
name: "Direct provider example",
|
||||
pluginId: "provider-direct-example",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
|
||||
patchDaemonConfig(config: {
|
||||
removeProviders?: string[];
|
||||
pluginsEnabled?: boolean;
|
||||
}): Promise<unknown>;
|
||||
installDirectoryPlugin(path: string): Promise<unknown>;
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
const pluginProviderRow = page.getByRole("button", {
|
||||
name: `${PLUGIN_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await plugin.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
||||
}
|
||||
|
||||
const MIN_TARGET_SIZE = 44;
|
||||
|
||||
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
||||
await expect(buttons).toHaveCount(5);
|
||||
for (const button of await buttons.all()) {
|
||||
const bounds = await button.boundingBox();
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(44);
|
||||
// Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
|
||||
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
|
||||
const glyphWidth = await button.locator("svg").first().getAttribute("width");
|
||||
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// Rows have no single accessible role: their nested title and actions share this container.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function reconnectApp(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectWorkerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-background-",
|
||||
title: "Background review",
|
||||
background: true,
|
||||
});
|
||||
try {
|
||||
const worker = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background reviewer",
|
||||
initialPrompt: "Background review prompt",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await test.step("Default sidebar hides background work", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Show background exposes the fully replicated workspace", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
@@ -445,6 +445,11 @@ test.describe("Sidebar workspace list", () => {
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
+74
-18
@@ -18,13 +18,15 @@ import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
@@ -98,7 +100,41 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
function installReversedUsageRefresh(page: Page) {
|
||||
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
||||
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
||||
return installUsageReportsFixture(page, {
|
||||
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
|
||||
});
|
||||
}
|
||||
|
||||
async function openUsageSummaryAtWidth(page: Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
if (width === 390) await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
}
|
||||
|
||||
async function expectUsageProviderOrder(page: Page, summary: string[]) {
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
|
||||
"opencode plan",
|
||||
"openai plan",
|
||||
]);
|
||||
await expectPinnedUsage(page, summary);
|
||||
}
|
||||
|
||||
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
|
||||
const requestCount = usage.listRequests().length;
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -134,14 +170,13 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await openUsageFromItem(page);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
@@ -151,6 +186,21 @@ test.describe("usage screen", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const usage = await installReversedUsageRefresh(page);
|
||||
await openUsageSummaryAtWidth(page, width);
|
||||
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
||||
|
||||
await refreshUsageAndWaitForReports(page, usage);
|
||||
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
||||
});
|
||||
}
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
@@ -176,7 +226,7 @@ test.describe("usage screen", () => {
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
// The sidebar summary and the modal each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
(request) =>
|
||||
@@ -187,7 +237,7 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -224,7 +274,9 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
@@ -238,8 +290,8 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
||||
// Without reports the footer has no Usage item; its Usage icon opens Usage.
|
||||
await openUsageFromIcon(page);
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
@@ -250,7 +302,7 @@ test.describe("usage screen", () => {
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
|
||||
test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(420_000);
|
||||
@@ -273,7 +325,7 @@ test.describe("usage screen", () => {
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
@@ -282,7 +334,7 @@ test.describe("usage screen", () => {
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
@@ -292,6 +344,7 @@ test.describe("usage screen", () => {
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
@@ -320,7 +373,7 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -366,7 +419,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
@@ -413,7 +467,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||
await expectCodexReportedWindows(page, shape);
|
||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||
@@ -439,7 +493,9 @@ for (const theme of ["light", "dark"] as const) {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
if (size === "compact") await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const settings = page.getByTestId("usage-options-menu");
|
||||
@@ -501,7 +557,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
]);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, [
|
||||
"Codex: Usage API returned 500. Try again.",
|
||||
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
import {
|
||||
openUsageFromIcon,
|
||||
refreshAllUsage,
|
||||
showUsageAs,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
@@ -98,7 +102,8 @@ test.describe("usage settings", () => {
|
||||
// The shared percentages setting applies to the host section.
|
||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await showUsageAs(page, "remaining");
|
||||
await page.goto(hostUsageUrl);
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
|
||||
@@ -2,6 +2,11 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
@@ -14,28 +19,29 @@ import {
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsageSheet,
|
||||
closeUsage,
|
||||
closeUsageFromBackdrop,
|
||||
closeUsageOptions,
|
||||
openUsageSheetFromIcon,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
usageModal,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -75,49 +81,98 @@ test.describe("Usage item", () => {
|
||||
await expectNoUsageItem(page);
|
||||
|
||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||
await openUsageSheetFromIcon(page);
|
||||
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await closeUsageSheet(page);
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await usageItem(page).click();
|
||||
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await openUsageFromItem(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
await closeUsageSheet(page);
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
});
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
|
||||
test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
const repo = await createTempGitRepo("usage-modal-");
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
await page.setViewportSize(WIDE);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
|
||||
await test.step("the summary opens a dialog over the workspace", async () => {
|
||||
await openUsageFromItem(page);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Refresh all", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await qaScreenshot(page, "desktop-usage-dialog");
|
||||
});
|
||||
|
||||
await test.step("Close returns to the same workspace", async () => {
|
||||
await closeUsage(page);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await waitForTabBar(page);
|
||||
});
|
||||
|
||||
await test.step("the icon opens it again, and Escape closes it", async () => {
|
||||
await openUsageFromIcon(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
});
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const layouts = {
|
||||
desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll between their first and last windows on ${size}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(viewport);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await showSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await openUsageFromItem(page);
|
||||
const content = usageModal(page);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsageToWindow(page, "Window 20");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsageToWindow(page, "Window 1");
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -146,8 +201,7 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
@@ -172,17 +226,13 @@ test.describe("Usage item", () => {
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await test.step("remaining flips the Usage item and the Usage modal", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
@@ -194,23 +244,15 @@ test.describe("Usage item", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await closeUsage(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await openUsageFromItem(page);
|
||||
const sheet = usageModal(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
@@ -231,25 +273,20 @@ test.describe("Usage item", () => {
|
||||
await closeUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await waitForSettledPosition(sheet);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
// The Usage sheet reserves the top 20% as its backdrop.
|
||||
// The sheet leaves the screen above it as its backdrop.
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await closeUsageFromBackdrop(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageFromItem(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -260,13 +297,15 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
const workspaceUrl = page.url();
|
||||
await closeUsage(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await page.goto(workspaceUrl);
|
||||
await openUsageFromItem(page);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -275,6 +314,7 @@ test.describe("Usage item", () => {
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoUsageItem(page);
|
||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
||||
@@ -282,6 +322,7 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||
await closeUsage(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
@@ -325,8 +366,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
||||
@@ -340,13 +380,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
await qaScreenshot(page, "usage-summary-off");
|
||||
});
|
||||
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
@@ -355,7 +395,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
await qaScreenshot(page, "usage-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
@@ -363,7 +403,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
@@ -391,8 +431,7 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
||||
@@ -400,5 +439,5 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-screen");
|
||||
await qaScreenshot(page, "released-host-usage-modal");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
|
||||
|
||||
async function openTabSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
const footer = page.getByRole("button", { name: "New terminal", exact: true });
|
||||
await expect(footer).toBeVisible();
|
||||
// Visibility alone passes while the sheet is still travelling up from below the viewport.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await footer.boundingBox();
|
||||
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
|
||||
}
|
||||
|
||||
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
|
||||
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
|
||||
}
|
||||
|
||||
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
|
||||
await openTabSwitcher(page);
|
||||
await page.getByRole("button", { name: kind, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await captureScreen(page, testInfo, "compact-chat-header");
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
|
||||
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switcher footer creates tabs and returns to the existing chat", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-header-actions-",
|
||||
title: "Existing chat",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
|
||||
await createTabFromSwitcher(page, "New terminal");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
|
||||
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-terminal-header");
|
||||
|
||||
await createTabFromSwitcher(page, "New agent");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-draft-header");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await captureScreen(page, testInfo, "switcher-three-tabs");
|
||||
await page.getByText("Existing chat", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await captureScreen(page, testInfo, "returned-chat-header");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Native compact workspace scripts flow
|
||||
|
||||
`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed.
|
||||
|
||||
The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`:
|
||||
|
||||
```json
|
||||
{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } }
|
||||
```
|
||||
|
||||
The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
context platform=android timeout=120000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env SCRIPT=worker
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 45000
|
||||
wait stable 1000 15000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-stop\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-run\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 10000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-restart\""
|
||||
wait stable 1000 15000
|
||||
wait "id=\"workspace-header-menu-trigger\"" 10000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "id=\"workspace-header-scripts\"" 10000
|
||||
press "id=\"workspace-header-scripts\""
|
||||
wait "text" "Running" 15000
|
||||
press "id=\"workspace-scripts-row-${SCRIPT}\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
|
||||
press "id=\"workspace-script-${SCRIPT}-stop\""
|
||||
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
|
||||
back
|
||||
wait stable 1000 15000
|
||||
close
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-surface\"" 10000
|
||||
press "id=\"terminal-surface\""
|
||||
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
||||
|
||||
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-header-menu-trigger"' --settle
|
||||
ad wait 'id="workspace-header-new-agent"' 10000
|
||||
ad press 'id="workspace-header-new-agent"' --settle
|
||||
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Native iOS text geometry regression
|
||||
|
||||
Run on the Namespace Mac with a fresh simulator development build. Use the PID
|
||||
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
|
||||
Attach only to your task's app, while no device replay is running:
|
||||
|
||||
```sh
|
||||
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
|
||||
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
|
||||
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
```
|
||||
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
This covers native container resizing and selection retention. It does not cover
|
||||
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
|
||||
menu. Those require the real chat journey: read an older multiline response,
|
||||
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
|
||||
retain expanded/collapsed details, and check a stationary reader's position.
|
||||
Accessibility exposes full text even when glyphs are clipped, so a text wait
|
||||
alone does not verify that journey.
|
||||
@@ -1,55 +0,0 @@
|
||||
import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
auto hostFrame = host.frame;
|
||||
hostFrame.size.width = 500;
|
||||
hostFrame.size.height = 200;
|
||||
host.frame = hostFrame;
|
||||
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
|
||||
container.frame = host.bounds;
|
||||
UITextView *text = nil;
|
||||
for (UIView *child in host.subviews) {
|
||||
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
|
||||
for (UIView *nested in child.subviews) {
|
||||
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
|
||||
}
|
||||
}
|
||||
text.frame = container.bounds;
|
||||
text.font = [UIFont systemFontOfSize:16];
|
||||
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
|
||||
auto range = text.selectedRange;
|
||||
range.location = 12;
|
||||
range.length = 9;
|
||||
text.selectedRange = range;
|
||||
auto frame = container.frame;
|
||||
frame.size.width = 240;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat narrow = text.frame.size.width;
|
||||
auto selection = text.selectedRange;
|
||||
frame.size.width = 500;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
selection.location == text.selectedRange.location &&
|
||||
selection.length == text.selectedRange.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide,
|
||||
(unsigned long)text.selectedRange.location,
|
||||
(unsigned long)text.selectedRange.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
if result.GetError().Fail():
|
||||
raise RuntimeError(result.GetError().GetCString())
|
||||
print(result.GetObjectDescription())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user