mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b7bf9a92 | ||
|
|
a01098ed31 | ||
|
|
4421f0473a | ||
|
|
2a432b1e0a | ||
|
|
1c175b36b3 | ||
|
|
aa9984b899 | ||
|
|
c4ff5599c7 | ||
|
|
ef146bc62d | ||
|
|
2e4dab0556 | ||
|
|
aa988dd8a8 | ||
|
|
781cb8d5b0 | ||
|
|
116b0a9190 | ||
|
|
5a7a60ae50 | ||
|
|
dd06bc0833 | ||
|
|
2eba291a8e | ||
|
|
96a143efe9 | ||
|
|
fe59a6c1a2 | ||
|
|
a5015dba80 | ||
|
|
da3be528e9 | ||
|
|
dc2dfdab93 | ||
|
|
fee32c9594 | ||
|
|
5b1baa0e72 | ||
|
|
fed8624516 | ||
|
|
5f89e666d0 | ||
|
|
41d6dc9cd7 | ||
|
|
6f685d37be | ||
|
|
286d528b71 | ||
|
|
8ff657cd4a | ||
|
|
45a991415f | ||
|
|
1ee13fff51 | ||
|
|
635b63a22b | ||
|
|
008eeaf18e | ||
|
|
ed8aa9ada8 | ||
|
|
7286d3ef81 | ||
|
|
a349856600 | ||
|
|
6016d3582e | ||
|
|
c1d7d56b71 | ||
|
|
2bd9e9d87f | ||
|
|
6e2fee462f | ||
|
|
cec92d0be6 | ||
|
|
b407990699 | ||
|
|
fc36438f03 | ||
|
|
0fb6473a4b | ||
|
|
a730ad1924 | ||
|
|
42c7b7f886 | ||
|
|
85fb5b436d | ||
|
|
c81f38d7ca | ||
|
|
be487ce65f | ||
|
|
0e0ebc7c9e | ||
|
|
7f3967d288 | ||
|
|
ade902f195 | ||
|
|
1bd94ac2b0 | ||
|
|
7deb338d4c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 | ||
|
|
d30aa16beb | ||
|
|
a6a37b568d | ||
|
|
93d21e5612 | ||
|
|
caae991a0f | ||
|
|
046ac7b339 | ||
|
|
6213df73dc | ||
|
|
02d3a05d2b | ||
|
|
175f36d64a | ||
|
|
dd2281d82c | ||
|
|
2a9395e2d1 | ||
|
|
85ce1aa828 | ||
|
|
d13438ba50 | ||
|
|
cf9eaa8d29 | ||
|
|
ec52097924 | ||
|
|
a30b784eb2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b | ||
|
|
8b3b2a9ea3 | ||
|
|
b6952995be | ||
|
|
c558af91eb | ||
|
|
7791dcee3b | ||
|
|
e532a24327 | ||
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe | ||
|
|
18c002cbf2 | ||
|
|
47a7463182 | ||
|
|
a4878656e2 | ||
|
|
aade09d056 | ||
|
|
a29e06bf1b | ||
|
|
a886bfbcdd | ||
|
|
88b46c6781 | ||
|
|
ee6a2bba77 | ||
|
|
83f4ef9909 | ||
|
|
a99f18b405 | ||
|
|
6e5742fbc6 | ||
|
|
97d39a0158 | ||
|
|
8b8957ab77 | ||
|
|
603ee5ced1 | ||
|
|
de1b85ecfb | ||
|
|
33c818cc6b | ||
|
|
de67a7370c | ||
|
|
f684804821 | ||
|
|
e7432cb2a5 | ||
|
|
4aeace7a17 |
+1
-6
@@ -530,7 +530,7 @@
|
||||
}
|
||||
},
|
||||
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
|
||||
// 94 files. They may only leave this list.
|
||||
// 85 files. They may only leave this list.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/ui/**/*.{ts,tsx}",
|
||||
@@ -539,12 +539,9 @@
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/attachment-pill.tsx",
|
||||
"packages/app/src/components/combined-model-selector.tsx",
|
||||
@@ -583,7 +580,6 @@
|
||||
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/components/workspace-hover-card.tsx",
|
||||
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
|
||||
@@ -619,7 +615,6 @@
|
||||
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
|
||||
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
|
||||
"packages/app/src/usage/sidebar-item.tsx",
|
||||
"packages/app/src/usage/window-bar.tsx",
|
||||
"packages/app/src/workspace-labels/manager-modal.tsx",
|
||||
|
||||
+14
-5
@@ -1,17 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
|
||||
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
|
||||
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **Plugin composer controls:** Plugins can add their own controls to the composer, and read and set its prompt, attachments, provider, model, mode and thinking. They can also put the cursor in any agent's composer, add items to New workspace, and store their own metadata on agents and workspaces. ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **Polish:** The chat stays still when a turn ends, and returning to a chat that has not changed shows it as it is with no refresh. Tool rows show their final title and icon from the first frame, the context window popover opens with usage already loaded, and sidebar hover cards open one at a time. ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
|
||||
- **Rebuilt mobile chat timeline:** The chat timeline on iOS and Android was rebuilt. Expanding or collapsing a turn summary, todo card or tool-call group keeps the line you tapped in place, and on Android so do Show more, Show less and plan cards. ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
|
||||
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
|
||||
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
|
||||
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. On desktop, sidebar workspace rows also get an archive button on hover. ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
|
||||
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
|
||||
### Added
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
|
||||
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
|
||||
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
|
||||
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
|
||||
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### Plugins
|
||||
|
||||
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
|
||||
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
|
||||
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
|
||||
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
|
||||
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
+14
-5
@@ -1,17 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
|
||||
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **插件 Composer 控件**:插件可以向 Composer 添加自己的控件,并读取和设置其中的提示词、附件、Provider、模型、模式和思考强度。插件还可以把光标放到任意 Agent 的 Composer 中、向“新建工作区”添加条目,并在 Agent 和工作区上保存自己的元数据。 ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
|
||||
- **重建移动端聊天时间线**:iOS 和 Android 上的聊天时间线已重建。展开或折叠轮次摘要、待办卡片或工具调用组时,你点按的那一行保持原位;在 Android 上,“显示更多”“收起”和计划卡片也是如此。 ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
|
||||
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
|
||||
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“Workspace 已归档”提示,持续六秒。点击“撤销”或按 Cmd/Ctrl+Z 可恢复该工作区及其标签页和 Agent。在桌面端,侧边栏的工作区行在悬停时还会显示归档按钮。 ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
|
||||
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
|
||||
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
|
||||
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
|
||||
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
|
||||
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### 插件
|
||||
|
||||
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
|
||||
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
|
||||
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
|
||||
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
|
||||
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
@@ -257,7 +257,7 @@ Workspace archive is a separate lifecycle. Archiving or removing a worktree can
|
||||
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
|
||||
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
the route exposes explicit Unarchive and Restore actions.
|
||||
|
||||
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
|
||||
The daemon runs one archive lane per workspace, shared by every connection
|
||||
@@ -281,9 +281,12 @@ does not flip it back. Tabs and layout are purged only once the undo window clos
|
||||
archive committed.
|
||||
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration. Other archived agents stay archived.
|
||||
**Unarchive**, **Restore**, Undo, and reopening the directory recover the agents closed by that
|
||||
workspace archive, including
|
||||
after a daemon restart or after the toast expires. Agents archived individually beforehand stay
|
||||
archived. A failed worktree recreation leaves the workspace and its agents archived for retry.
|
||||
Each recovered agent runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration.
|
||||
|
||||
Opening an agent is a navigation choice, independent of whether its details are cached. The
|
||||
layout retains that choice across reload while the panel fetches the agent from the daemon.
|
||||
|
||||
@@ -39,6 +39,19 @@ The trigger can only stay put when the list has scroll room for the change. When
|
||||
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
||||
ends at its end and the trigger moves by the difference.
|
||||
|
||||
## State that survives a remount
|
||||
|
||||
The list mounts a row again whenever it opens the chat, brings a web row back into the history
|
||||
window, or reassigns a native row's LegendList container. State held in the row resets each time.
|
||||
Keep a disclosure's open state in the per-agent store the timeline provides, keyed by item id:
|
||||
`ToolCallExpansionContext` for tool calls, `UserMessageExpansionContext` for long user messages.
|
||||
|
||||
Decide a row's first painted shape in the commit that mounts it. `MessagePreview` measures its body
|
||||
in a layout effect, which commits before paint on web and Fabric. `onLayout` lands after the first
|
||||
frame: a long message measured there paints capped with no fade or show more, then grows by the
|
||||
control's height a frame later. `onLayout` still tracks later size changes. A zero height comes from
|
||||
a hidden panel and is ignored.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
@@ -143,8 +156,10 @@ keeps following.
|
||||
|
||||
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
|
||||
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
|
||||
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
|
||||
with the scroll-to-bottom pill, so the rows the user let go of never move.
|
||||
land during the bounce must keep scrolling in. A release short of the end stays detached with the scroll-to-bottom pill unless you submitted
|
||||
another prompt during that gesture. That submit requests the end after the gesture finishes;
|
||||
a new drag after submitting takes ownership again. Rewind and tail-fetch completions do not
|
||||
override a scroll away because they can arrive after the action that requested them.
|
||||
|
||||
### Draw distance
|
||||
|
||||
@@ -173,7 +188,8 @@ Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
|
||||
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
|
||||
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
|
||||
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover Android; iOS has no native
|
||||
test harness.
|
||||
|
||||
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
|
||||
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
|
||||
@@ -193,5 +209,16 @@ React Native 0.88 skips the adjustment for a removed anchor
|
||||
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
||||
Native counterpart; it stays while the timeline uses the pin.
|
||||
|
||||
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
|
||||
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
|
||||
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
|
||||
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
|
||||
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
|
||||
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
|
||||
A change in the timeline's height, such as the composer below it growing, also reveals the focused
|
||||
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
|
||||
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
|
||||
went with it; keep this one while chat text is selectable.
|
||||
|
||||
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
||||
their fixes.
|
||||
|
||||
+13
-4
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
|
||||
from `packages/app/android`:
|
||||
|
||||
```bash
|
||||
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
@@ -177,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
## Scroll view corrections
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
keeps React Native's scroll manager interface and corrects how the chat timeline holds its position.
|
||||
[agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) lists the
|
||||
corrections and the React Native issues they work around. Its instrumentation tests run the scroll
|
||||
view in a real window, mounting through React Native's own mounting manager and injecting touches:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
+10
-6
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -40,9 +40,11 @@ Weight has three tiers, applied by role:
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
@@ -70,7 +72,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
|
||||
|
||||
@@ -112,6 +114,8 @@ Five primitives. The pick is determined by option count, the need to search, and
|
||||
|
||||
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||
|
||||
The sheet header (`SheetHeaderView`) is the only sheet title row. A sheet that brings its own Gorhom modal still renders it. Back, Close and every action passed through `header.actions` are `SheetHeaderIconButton`, so all of them get the header island's chrome, as `CompactHeader` does: one frame, glyph, colour and hover. The first and last buttons hang into the row's padding, so their glyphs sit on the content rail.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
@@ -132,7 +136,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand. Settings rows that share a menu surface with menu rows, such as Usage's options, go inside `<MenuSettingsRows>`, which puts their labels on the menu rows' label rail (`menuRowContentInset`).
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
@@ -267,7 +271,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ than downloading a published desktop release.
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
+39
-9
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
| File | Use case |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
@@ -65,6 +65,10 @@ Two escape hatches in the codebase:
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
|
||||
modal's first frames at the screen size minus the status and navigation bars, then grows it to
|
||||
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
|
||||
down. The wrapper sizes the content to the window from the first frame.
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
@@ -280,6 +284,22 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
|
||||
A measured rect is an asynchronous copy, so it lands after the anchor's new
|
||||
layout has painted. The composer resizes while the autocomplete is open: a
|
||||
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
|
||||
the anchor's rect against the layer and commits the placement with `flushSync`,
|
||||
which runs after layout and before paint. On native a Reanimated frame callback
|
||||
reads only the anchor's height on the UI thread and holds its bottom edge at
|
||||
the rest measurement. A height carries no transform. On iOS the absolute
|
||||
position does not reliably include one: the keyboard lift runs on the native
|
||||
driver and reaches the shadow tree only when the view re-renders. The
|
||||
fixed-bottom model is wrong for the centered New workspace composer on tablets,
|
||||
which grows around its middle, and for width or x changes. The popover is off
|
||||
there by half the growth, or by the old width, until the next rest measurement.
|
||||
Keep the portal's `KeyboardTranslateView` host-sized with
|
||||
`pointerEvents="box-none"` and position the popover inside it, so the popover
|
||||
moves without leaving its parent's bounds (Gotcha 1).
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
||||
@@ -422,9 +442,11 @@ gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
- An RN `Modal` passes the call site's context straight through, so wrap its content in
|
||||
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
|
||||
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
|
||||
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
|
||||
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
@@ -453,6 +475,14 @@ Before you write a new one, ask:
|
||||
`top` paints over its anchor until the next placement lands, about 150 ms
|
||||
on Android. Web hides this because React commits the resize and the
|
||||
re-placement in one task.
|
||||
7. **Does the panel hold a list that reads away from its anchor?** Make the
|
||||
anchor edge the list's resting scroll position: an inverted list, as in
|
||||
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
|
||||
the rows commit reaches the screen one or two frames after them, so a list
|
||||
scrolled to its anchor edge paints its far end first. Give each result set
|
||||
its own list with a `key`, so it starts at the anchor edge with the rows
|
||||
that fill the panel. A list that mounts every result holds the panel's
|
||||
first frame until the last row is ready.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
|
||||
|
||||
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
@@ -52,6 +59,8 @@ Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
|
||||
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
|
||||
layer gets first refusal; the latest registration wins a tie. Native registration
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
Declaring Tab takes it from the web focus trap and hands it to `onKeyDown`; the command
|
||||
center rotates its tabs with it. Native sources drop Tab from the declarations.
|
||||
|
||||
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
|
||||
be discarded or removed by React Compiler; recomputing against the registry lets an
|
||||
@@ -85,6 +94,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
Terminal arrows never pass through the shortcut pipeline. The Android hardware
|
||||
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
|
||||
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
|
||||
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
|
||||
@@ -12,6 +12,8 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
|
||||
|
||||
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. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas).
|
||||
|
||||
Pass `external` for author-controlled Markdown from outside the host, such as a plugin README. It follows the registry's overview contract in `packages/protocol/src/plugin-overview.ts`, which the website also uses: only absolute https links and images work, and any other destination renders as its label or alt text. The parser still autolinks bare `http://`, `www.` and email text, so a link press checks the URL again and opens only https. None of the workspace recognition above applies, so `Node.js` is prose, not a file. The lines of a paragraph join, as they do on the README's forge.
|
||||
|
||||
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. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
|
||||
|
||||
## Engine constraints
|
||||
|
||||
+13
-4
@@ -27,9 +27,17 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
|
||||
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
|
||||
laid out on native. Its target and scroll position survive until return to the originating
|
||||
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
|
||||
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
|
||||
Input and activity stop during the handoff. Widening or leaving for another destination discards
|
||||
the return. Commands issued while suspended settle immediately behind Settings, so explicit
|
||||
agent navigation does not reveal a closing sidebar animation.
|
||||
|
||||
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
|
||||
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
@@ -120,7 +128,8 @@ definition, no longer eligible to begin.
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
workspace destinations close the overlay before navigating. Settings suspends the compact
|
||||
sidebar for the duration of its visit instead.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
+19
-3
@@ -624,6 +624,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
@@ -858,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
|
||||
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
+10
-1
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Pi resumed subagents — #9200
|
||||
|
||||
## Scope and provenance
|
||||
|
||||
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
|
||||
|
||||
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
|
||||
|
||||
## Before and automated checks
|
||||
|
||||
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
|
||||
|
||||
[Captured command output](validation.txt) contains:
|
||||
|
||||
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
|
||||
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
|
||||
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
|
||||
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
|
||||
|
||||
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
|
||||
|
||||
## Real desktop steps and observations
|
||||
|
||||
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
|
||||
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
|
||||
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
|
||||
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
|
||||
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
|
||||
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
|
||||
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
|
||||
|
||||
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
|
||||
|
||||
**C running in the native provider-owned track:**
|
||||
|
||||

|
||||
|
||||
**After archiving finished rows, C remains running:**
|
||||
|
||||

|
||||
|
||||
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
|
||||
|
||||

|
||||
|
||||
**Final four completed rows, with the failed resume reported above:**
|
||||
|
||||

|
||||
|
||||
## Coverage and limits
|
||||
|
||||
| Surface | Tested |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------- |
|
||||
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
|
||||
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
|
||||
| Standalone browser web | No |
|
||||
| iOS / Android | No |
|
||||
| Windows / Linux desktop | No |
|
||||
| Packaged release / CI | No |
|
||||
|
||||
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
|
||||
|
||||
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
|
||||
"calls": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:31.691Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"arguments": {
|
||||
"agent": "qa-marker",
|
||||
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
|
||||
"async": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:32.010Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:22.901Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:23.219Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:36.923Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:37.597Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.099Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"message": "Output PASEO_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.109Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"isError": true,
|
||||
"details": {},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.330Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.668Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"finalStatuses": [
|
||||
{
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"state": "complete",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"state": "complete",
|
||||
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
}
|
||||
],
|
||||
"completionNotices": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:09:07.245Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:53.423Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:14:37.834Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:37.887Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
|
||||
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
|
||||
|
||||
Task: You are reviving a previous subagent conversation.
|
||||
|
||||
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
|
||||
Original agent: qa-marker
|
||||
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
|
||||
|
||||
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
|
||||
|
||||
Follow-up:
|
||||
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
|
||||
Show more
|
||||
12:17 PM
|
||||
QA #9200 — Pi resume A→B→C
|
||||
Open parent
|
||||
@@ -0,0 +1,196 @@
|
||||
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
|
||||
RED runs are intentional regression failures, not final failures.
|
||||
|
||||
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
|
||||
× registers a successful resume as a new async attempt without spawn arguments 6ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
|
||||
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
|
||||
|
||||
- Expected:
|
||||
[
|
||||
{
|
||||
"description": "Continue",
|
||||
"id": "resume-b",
|
||||
"status": "running",
|
||||
"title": "Subagent",
|
||||
"toolCallId": "resume-b",
|
||||
"type": "upsert",
|
||||
},
|
||||
]
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
|
||||
64| },
|
||||
65| });
|
||||
66| expect(resumed?.subagents).toEqual([
|
||||
| ^
|
||||
67| {
|
||||
68| type: "upsert",
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed (10)
|
||||
Start at 21:05:21
|
||||
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
|
||||
|
||||
|
||||
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
|
||||
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
|
||||
AssertionError: expected false to be true // Object.is equality
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
- true
|
||||
+ false
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
|
||||
153| ]);
|
||||
154| replay.mapToolCall(call);
|
||||
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
|
||||
| ^
|
||||
156| }
|
||||
157| const oldNotice = {
|
||||
|
||||
Caused by: Error: Matcher did not succeed in time.
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed | 6 passed (16)
|
||||
Start at 21:09:04
|
||||
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
|
||||
|
||||
|
||||
=== Final baseline d46ee655: GREEN, exit 0 ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 21 passed (21)
|
||||
Start at 13:07:04
|
||||
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
|
||||
|
||||
|
||||
=== Final typecheck, exit 0 ===
|
||||
$ npm run typecheck
|
||||
|
||||
> paseo@0.15.4 typecheck
|
||||
> npm run typecheck --workspaces --if-present
|
||||
|
||||
|
||||
> @getpaseo/builtin-plugins@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/highlight@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck
|
||||
> tsgo --noEmit && npm run typecheck:examples
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck:examples
|
||||
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
|
||||
|
||||
|
||||
up to date in 433ms
|
||||
|
||||
> @getpaseo/protocol@0.15.4 pretypecheck
|
||||
> npm run generate:validators
|
||||
|
||||
|
||||
> @getpaseo/protocol@0.15.4 generate:validators
|
||||
> node scripts/generate-validation-aot.mjs
|
||||
|
||||
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
|
||||
|
||||
> @getpaseo/protocol@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/client@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/server@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.server.typecheck.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/app@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/relay@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/website@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/desktop@0.15.4 typecheck
|
||||
> tsgo --noEmit -p tsconfig.json
|
||||
|
||||
|
||||
> @getpaseo/cli@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
=== Final lint, exit 0 ===
|
||||
$ npm run lint
|
||||
|
||||
> paseo@0.15.4 lint
|
||||
> oxlint
|
||||
|
||||
Found 0 warnings and 0 errors.
|
||||
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
|
||||
|
||||
=== Final full formatter, exit 0; no unrelated changes ===
|
||||
$ npm run format
|
||||
|
||||
> paseo@0.15.4 format
|
||||
> oxfmt .
|
||||
|
||||
Finished in 2684ms on 5928 files using 12 threads.
|
||||
|
||||
$ git diff --check
|
||||
(exit 0; no output)
|
||||
+32
-6
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
@@ -669,9 +670,9 @@ Every section bullet must be scannable at a glance. The changelog is not release
|
||||
## Changelog highlights
|
||||
|
||||
Every release entry leads with highlights: the changes a user would name first when asked what is
|
||||
new. They are a list above the first `###` section, biggest first. The sections below are the
|
||||
full record; a reader who stops after the highlights should have missed nothing they would ask
|
||||
about.
|
||||
new. They are a list above the first `###` section, ranked as
|
||||
[Changelog ordering](#changelog-ordering) describes. The sections below are the full record; a
|
||||
reader who stops after the highlights should have missed nothing they would ask about.
|
||||
|
||||
```markdown
|
||||
## 0.16.0 - 2026-11-20
|
||||
@@ -715,7 +716,7 @@ about.
|
||||
| The same shortcuts as desktop: command center, file picker, new workspace, workspace jumps | The same shortcuts as desktop, including your custom bindings |
|
||||
|
||||
Before showing a draft, read only the bold lead-ins top to bottom. Each one must answer "what is
|
||||
new?" without its description.
|
||||
new?" without its description, and each must outrank the one below it.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
@@ -748,7 +749,32 @@ Rules:
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Highlights come first, ordered by user impact. Entries within each section (Added, Improved, Fixed) follow the same order:
|
||||
Rank highlights by how much of a user's day the change touches: how many users get it, and how
|
||||
often they meet it. Put every highlight in one of four groups, then order each group by the same
|
||||
question.
|
||||
|
||||
1. **New capabilities.** Something a user could not see or do in the last release:
|
||||
`Background tasks`, `Plugin sidebar views`.
|
||||
2. **Everyday things that got better.** Speed, steadiness and polish in what users already do
|
||||
every session: `Faster diff loading`, `Polish`.
|
||||
3. **Changes for part of the audience.** They need a particular device, provider or setup:
|
||||
`Keyboard shortcuts on mobile`, `Touch-sized tablet controls`.
|
||||
4. **Small conveniences and highlighted fixes.** A user is glad to find them and would not update
|
||||
for them: `Undo workspace archive`, `Fewer reconnects on return`. Fixes are routine, so one
|
||||
that earned a highlight still follows everything new or improved.
|
||||
|
||||
- **Reach decides before kind.** A new capability that needs a hardware keyboard is in group 3.
|
||||
- **Rank the whole list in one pass.** The order you collected the pull requests in, the alphabet
|
||||
and the order of the sections below are not rankings.
|
||||
- **Show the groups with the draft.** List each highlight under its group when you ask for
|
||||
approval, so the maintainer corrects the test and not one position.
|
||||
- **A maintainer's note that a change is big moves that change up.** It does not rank the changes
|
||||
it names against each other, and it does not lower the ones it leaves out.
|
||||
- **A correction to one highlight is a correction to the test.** Work out which group or question
|
||||
you got wrong, rank every highlight again, and then edit the file. Moving only the named item
|
||||
leaves the same mistake in the others.
|
||||
|
||||
Bullets within each section (Added, Changed, Fixed) follow this order:
|
||||
|
||||
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||
|
||||
@@ -95,10 +95,11 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
|
||||
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
|
||||
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
|
||||
updated after the checkpoint does not block it. Forking the current context remains available.
|
||||
includes projected items through that checkpoint. A tool call that started before the checkpoint
|
||||
and changed after it, such as background work that outlives a reply, is copied as still running:
|
||||
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
|
||||
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
|
||||
forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
|
||||
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/server": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/server": "0.16.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.4",
|
||||
"@getpaseo/highlight": "0.15.4",
|
||||
"@getpaseo/plugin": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/highlight": "0.16.0-beta.2",
|
||||
"@getpaseo/plugin": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,7 +5,7 @@ import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
test("agent list age advances and command center says just now for a fresh workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -23,10 +23,10 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
const workspaceResult = commandCenter.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${session.workspaceId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
await expect(workspaceResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -37,7 +37,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(212, 212, 216)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar(
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
||||
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
||||
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
||||
// Desktop-width sidebars rename in place.
|
||||
const renameInput = page.getByTestId(
|
||||
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
|
||||
);
|
||||
await expect(renameInput).toBeFocused({ timeout: 10_000 });
|
||||
await renameInput.fill(input.title);
|
||||
await page.getByTestId(`${modalPrefix}-submit`).click();
|
||||
await renameInput.press("Enter");
|
||||
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2297,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { openWorkspaceFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
async function isWorkspaceListed(seeded: SeededWorkspace): Promise<boolean> {
|
||||
const result = await seeded.client.fetchWorkspaces();
|
||||
return result.entries.some((entry) => entry.id === seeded.workspaceId);
|
||||
}
|
||||
|
||||
async function expectWorkspaceArchivedOnDaemon(seeded: SeededWorkspace): Promise<void> {
|
||||
await expect.poll(() => isWorkspaceListed(seeded), { timeout: 30_000 }).toBe(false);
|
||||
}
|
||||
|
||||
test.describe("Command center archive for a non-Git workspace", () => {
|
||||
test("archives a plain directory workspace from the command center", async ({ page }) => {
|
||||
const title = "Command Center Directory Archive";
|
||||
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-", title, git: false });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, seeded.workspaceId);
|
||||
expect(existsSync(seeded.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archives a plain directory workspace from the command center with the sidebar closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = "Command Center Directory Archive Closed Sidebar";
|
||||
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-closed-", title, git: false });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+k`);
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceArchivedOnDaemon(seeded);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archives a plain directory workspace from the command center with its project collapsed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = "Command Center Directory Archive Collapsed Project";
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "cc-archive-dir-collapsed-",
|
||||
title,
|
||||
git: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await page
|
||||
.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.first()
|
||||
.click();
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("archive");
|
||||
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
|
||||
await expect(archive).toBeVisible({ timeout: 30_000 });
|
||||
await archive.click();
|
||||
|
||||
await expectWorkspaceArchivedOnDaemon(seeded);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeCommandCenter,
|
||||
expectCommandCenterTab,
|
||||
expectCommandCenterTabs,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
@@ -17,6 +24,7 @@ import {
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const FILE_PATH =
|
||||
@@ -67,7 +75,7 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill(FILE_NAME);
|
||||
@@ -97,26 +105,90 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
repoPrefix: "command-center-tabs-",
|
||||
title: "Tab rotation",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
await test.step("⌘P opens on Files and Backspace on an empty field returns to All", async () => {
|
||||
await page.keyboard.press("Meta+P");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
});
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
const results = panel.getByTestId("command-center-results");
|
||||
const headerOnAll = await header.boundingBox();
|
||||
const resultsTopOnAll = (await results.boundingBox())?.y;
|
||||
|
||||
await test.step("Tab walks forward and wraps; Shift+Tab walks back", async () => {
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Workspaces");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
expect(await header.boundingBox()).toEqual(headerOnAll);
|
||||
expect((await results.boundingBox())?.y).toBe(resultsTopOnAll);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
});
|
||||
|
||||
await test.step("rotating keeps the query", async () => {
|
||||
await input.fill("tab rot");
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions");
|
||||
await expect(input).toHaveValue("tab rot");
|
||||
});
|
||||
|
||||
await test.step("clicking a tab keeps typing in the search field", async () => {
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(input).toHaveValue("tab ro");
|
||||
await expect(
|
||||
panel.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("⌘K opens on All", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible();
|
||||
await page.keyboard.press("Meta+K");
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
});
|
||||
|
||||
await test.step("in a workspace every tab is offered", async () => {
|
||||
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Files", "Actions"]);
|
||||
});
|
||||
|
||||
await test.step("outside a workspace there is no Files tab, and Tab skips it", async () => {
|
||||
const outside = { workspaceOpen: false };
|
||||
await closeCommandCenter(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Meta+K");
|
||||
await expectCommandCenterTab(panel, "All", outside);
|
||||
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Actions"]);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Workspaces", outside);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "Actions", outside);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectCommandCenterTab(panel, "All", outside);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expectCommandCenterTab(panel, "Actions", outside);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
expectCommandCenterWorkspaceWindow,
|
||||
scrollToOldestCommandCenterWorkspace,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
expectWorkspaceResultsToBeLimitedUntilSearch,
|
||||
expectRecentWorkspacesUntilSearch,
|
||||
observeCommandCenterScroll,
|
||||
openCommandCenterWithKeyboard,
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
@@ -63,9 +63,9 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
// All leads with the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
await expectRecentWorkspacesUntilSearch(panel);
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
|
||||
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
|
||||
@@ -76,42 +76,50 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
test("live search updates render only the changed row and keep workspaces virtualized", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedSearchableCommandCenter();
|
||||
const agentWorkspaceRowId = `workspace:${getServerId()}:${seeded.workspaceId}`;
|
||||
try {
|
||||
await observeCommandCenterRowRenders(page);
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
|
||||
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
|
||||
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`agent:${getServerId()}:${seeded.agents[59].id}`,
|
||||
);
|
||||
await test.step("renaming a matched agent re-renders only its workspace", async () => {
|
||||
await searchCommandCenter(panel, "Search agent 002");
|
||||
const agentLine = panel
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.getByTestId("command-center-workspace-agent");
|
||||
await expect(agentLine).toHaveText("Search agent 002");
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.updateAgent(seeded.agents[2].id, { name: "Search agent 002 revised" });
|
||||
await expect(agentLine).toHaveText("Search agent 002 revised");
|
||||
await expectOnlyCommandCenterRowRendered(page, agentWorkspaceRowId);
|
||||
});
|
||||
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
await test.step("renaming a workspace re-renders only its row", async () => {
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(
|
||||
seeded.workspaces[39].id,
|
||||
"Search workspace 039 revised",
|
||||
);
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
});
|
||||
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
await test.step("a long list mounts only a window of rows", async () => {
|
||||
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
|
||||
await scrollToOldestCommandCenterWorkspace(panel);
|
||||
await expect(panel.getByText("Search workspace 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
commandCenterSection,
|
||||
expectCommandCenterTab,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -21,9 +26,7 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("workspace results overlay live status", async ({ page }) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
@@ -34,15 +37,13 @@ test.describe("Command center workspaces", () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
|
||||
await workspace.client.cancelAgent(workspace.agentId);
|
||||
await expect(agent.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(row.getByRole("progressbar")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -80,9 +81,11 @@ test.describe("Command center workspaces", () => {
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
const agentLine = row.getByTestId("command-center-workspace-agent");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(WORKSPACE_TITLE);
|
||||
await expect(row).toContainText(WORKSPACE_BRANCH);
|
||||
@@ -92,65 +95,151 @@ test.describe("Command center workspaces", () => {
|
||||
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(subtitle).toContainText(seeded.projectDisplayName);
|
||||
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
|
||||
await expect(agentLine).toHaveCount(0);
|
||||
|
||||
// The agent subtitle is unchanged by the shared-helper refactor.
|
||||
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(agentRow).toContainText(AGENT_TITLE);
|
||||
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
|
||||
await expect(agentRow).toContainText(WORKSPACE_TITLE);
|
||||
await expect(agentRow).not.toContainText(seeded.repoPath);
|
||||
|
||||
const workspaceSectionTop = await panel
|
||||
.getByText("Workspaces", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const agentSectionTop = await panel
|
||||
.getByText("Agents", { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
|
||||
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill(PRIMARY_HOST_LABEL);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_BRANCH);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentRow).toBeVisible();
|
||||
|
||||
await input.fill(seeded.repoPath);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
// The project name is now part of the workspace searchText.
|
||||
await input.fill(seeded.projectDisplayName);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await input.fill(AGENT_TITLE);
|
||||
await expect(agentRow).toBeVisible();
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
await input.fill(WORKSPACE_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
await test.step("a workspace matches on its own fields", async () => {
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
for (const query of [
|
||||
PRIMARY_HOST_LABEL,
|
||||
WORKSPACE_BRANCH,
|
||||
WORKSPACE_TITLE,
|
||||
seeded.projectDisplayName,
|
||||
]) {
|
||||
await input.fill(query);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(agentLine).toHaveCount(0);
|
||||
}
|
||||
// `cwd` is never shown, so it must never match.
|
||||
await input.fill(seeded.repoPath);
|
||||
await expect(row).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("find an agent and open its workspace tab", async () => {
|
||||
const agentPanel = await openCommandCenter(page);
|
||||
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await test.step("an agent title finds its workspace and names the agent", async () => {
|
||||
await panel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await expect(agentLine).toHaveText(AGENT_TITLE);
|
||||
await expect(panel.getByText("Agents", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
});
|
||||
|
||||
await test.step("a workspace match opens the workspace", async () => {
|
||||
const searchAgain = await openCommandCenter(page);
|
||||
await expectCommandCenterTab(searchAgain, "All");
|
||||
await searchAgain.getByTestId("command-center-input").fill(WORKSPACE_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("All leads with the three most recent workspaces other than the open one", async ({
|
||||
page,
|
||||
}) => {
|
||||
const older = [];
|
||||
for (const title of ["Recent oldest", "Recent third", "Recent second", "Recent newest"]) {
|
||||
older.push(await seedWorkspace({ repoPrefix: "command-center-recent-", title }));
|
||||
}
|
||||
const current = await seedWorkspace({
|
||||
repoPrefix: "command-center-recent-",
|
||||
title: "Open one",
|
||||
});
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), current.workspaceId));
|
||||
const panel = await openCommandCenter(page);
|
||||
await expectCommandCenterTab(panel, "All");
|
||||
await expect(commandCenterSection(panel, "Recent workspaces")).toBeVisible();
|
||||
const rowOf = (workspaceId: string) =>
|
||||
panel.getByTestId(`command-center-workspace-${getServerId()}:${workspaceId}`);
|
||||
|
||||
await expect(rowOf(older[3].workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rowOf(older[2].workspaceId)).toBeVisible();
|
||||
await expect(rowOf(older[1].workspaceId)).toBeVisible();
|
||||
await expect(rowOf(older[0].workspaceId)).toHaveCount(0);
|
||||
await expect(rowOf(current.workspaceId)).toHaveCount(0);
|
||||
await expect(commandCenterSection(panel, "Workspaces")).toHaveCount(0);
|
||||
|
||||
// The newest recent workspace is selected, so Enter goes straight back to it.
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), older[3].workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
for (const seeded of [current, ...older]) await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("archived workspaces follow active matches and open where they can be unarchived", async ({
|
||||
page,
|
||||
}) => {
|
||||
const active = await seedWorkspace({
|
||||
repoPrefix: "command-center-archived-active-",
|
||||
title: "Billing dashboard",
|
||||
});
|
||||
const archived = await seedWorkspace({
|
||||
repoPrefix: "command-center-archived-",
|
||||
title: "Billing cleanup",
|
||||
});
|
||||
try {
|
||||
await createIdleAgent(archived.client, {
|
||||
cwd: archived.repoPath,
|
||||
workspaceId: archived.workspaceId,
|
||||
title: "Audit invoice totals",
|
||||
});
|
||||
expect((await archived.client.archiveWorkspace(archived.workspaceId)).error).toBeNull();
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
const activeRow = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${active.workspaceId}`,
|
||||
);
|
||||
const archivedRow = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${archived.workspaceId}`,
|
||||
);
|
||||
|
||||
await test.step("an empty query never lists archived workspaces", async () => {
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
await expect(activeRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(archivedRow).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a query lists the archived workspace after the active one", async () => {
|
||||
await input.fill("billing");
|
||||
await expect(archivedRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(archivedRow).toContainText("Archived");
|
||||
await expect(activeRow).not.toContainText("Archived");
|
||||
const activeTop = (await activeRow.boundingBox())?.y ?? Number.NaN;
|
||||
const archivedTop = (await archivedRow.boundingBox())?.y ?? Number.NaN;
|
||||
expect(archivedTop).toBeGreaterThan(activeTop);
|
||||
});
|
||||
|
||||
await test.step("an agent title finds the archived workspace on All", async () => {
|
||||
// Opened from the app shell, outside any workspace.
|
||||
await selectCommandCenterTab(panel, "All", { workspaceOpen: false });
|
||||
await input.fill("invoice totals");
|
||||
await expect(archivedRow.getByTestId("command-center-workspace-agent")).toHaveText(
|
||||
"Audit invoice totals",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(activeRow).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("opening it lands where the workspace can be unarchived", async () => {
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await active.cleanup();
|
||||
await archived.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -229,7 +318,7 @@ test.describe("Compact sidebar inspection", () => {
|
||||
title: "Compact destination",
|
||||
});
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Compact root agent",
|
||||
@@ -246,7 +335,10 @@ test.describe("Compact sidebar inspection", () => {
|
||||
await openSidebar(page);
|
||||
const agentSearch = await openCommandCenter(page);
|
||||
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
|
||||
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
|
||||
await agentSearch
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent...", exact: true }),
|
||||
|
||||
@@ -787,9 +787,22 @@ test.describe("Composer autocomplete", () => {
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
// On web the search reads the cursor after paint, so the input's value runs ahead of
|
||||
// the list. Wait on the list itself. The list mounts only the rows near its scroll
|
||||
// position, so one missing row proves nothing: right after the arrows wrap to the top,
|
||||
// /tdd at the bottom is unmounted while the search is still "". With the search at "",
|
||||
// the list always mounts the bottom row (/tdd) or the chosen one (/exit); only the
|
||||
// search "h" leaves both out. Check them in one query so no frame falls between them.
|
||||
await expect(
|
||||
popover.filter({
|
||||
has: page.getByText("/help", { exact: true }),
|
||||
hasNot: page.getByText(/^\/(tdd|exit)$/),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
// /tdd is back only once the search is "" again.
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveValue("/tdd ");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
selectComposerDraftRange,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
@@ -32,31 +33,33 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
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("Enter sends email formats 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);
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
await test.step(`complete a file mention, then send ${email} intact`, async () => {
|
||||
await selectReadmeMention(page);
|
||||
await sendEmailIntact(page, email);
|
||||
// Start the next draft only after the daemon and visible agent have settled.
|
||||
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
|
||||
page,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
});
|
||||
await test.step("recall a slash message without opening its picker, then edit it", async () => {
|
||||
await expectComposerDraft(page, "");
|
||||
const reply = await session.client.waitForFinish(session.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 4);
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerControl,
|
||||
expectGlyphMatchesLabel,
|
||||
expectIconOnlyGlyph,
|
||||
readComposerControlTone,
|
||||
readComposerPlaceholderTone,
|
||||
readComposerTextTone,
|
||||
} from "../support/helpers/composer-toolbar-tone";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const THINKING = "agent-thinking-selector";
|
||||
const MODE = "mode-control";
|
||||
const ATTACH = "message-input-attach-button";
|
||||
const COMBINED_MODEL = "combined-model-selector";
|
||||
|
||||
async function openSeededComposer(
|
||||
page: import("@playwright/test").Page,
|
||||
repoPrefix: string,
|
||||
readyTestId = MODE,
|
||||
) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Toolbar tone",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Composer toolbar glyph tone", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test.describe("on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-touch-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
thinking.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
|
||||
|
||||
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-compact-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectIconOnlyGlyph(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
|
||||
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
mode.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a folded compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
|
||||
|
||||
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
|
||||
try {
|
||||
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
|
||||
const extraMuted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-thinking-label"),
|
||||
);
|
||||
const muted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-model-label"),
|
||||
);
|
||||
|
||||
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
|
||||
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
|
||||
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
|
||||
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("with a wide pointer", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-pointer-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,8 +127,59 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("retrying failed agent initialization keeps the plugin metadata set on New workspace", async ({
|
||||
creation,
|
||||
startup,
|
||||
composerMetadata,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await composerMetadata.set({ "composer-metadata.tag": "kept" });
|
||||
await creation.submitPrompt("Start the tagged task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.submitPrompt("Start the tagged task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectAgentMetadata({ "composer-metadata.tag": "kept" });
|
||||
});
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -159,7 +210,9 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
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.");
|
||||
|
||||
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
const LAYOUT_VIEWPORTS = {
|
||||
compact: { width: 390, height: 844 },
|
||||
wide: { width: 1280, height: 900 },
|
||||
};
|
||||
|
||||
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
|
||||
// switch so the next step reads the layout it resized to.
|
||||
async function resizeToLayout(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
layout: keyof typeof LAYOUT_VIEWPORTS,
|
||||
) {
|
||||
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
|
||||
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
downloadFileFromPreview,
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
@@ -141,6 +142,29 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
|
||||
const filePath = testInfo.outputPath("outside.bin");
|
||||
const source = Buffer.from([0, 1, 128, 255]);
|
||||
await writeFile(filePath, source);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "outside-binary-",
|
||||
title: "Outside binary",
|
||||
initialPrompt: "Show the file",
|
||||
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "Open binary");
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe("outside.bin");
|
||||
const savedFile = testInfo.outputPath("downloaded.bin");
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
|
||||
@@ -2,7 +2,12 @@ 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 { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
downloadFileFromExplorer,
|
||||
downloadFileFromPreview,
|
||||
openFileFromExplorer,
|
||||
openFileExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -473,3 +478,21 @@ test("downloads a file over direct TCP with its original filename and bytes", as
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
test("downloads an unsupported binary from its preview with 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);
|
||||
await openFileFromExplorer(page, fileName);
|
||||
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
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,56 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const HEADER_ICON_BUTTONS = ["workspace-header-menu-trigger", "workspace-explorer-toggle"];
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
function matchesFocusVisible(target: Locator): Promise<boolean> {
|
||||
return target.evaluate((el) => el.matches(":focus-visible"));
|
||||
}
|
||||
|
||||
async function expectKeyboardFocusRing(page: Page, testId: string): Promise<void> {
|
||||
const button = visible(page, testId);
|
||||
// Chromium matches :focus-visible on a programmatic focus that follows keyboard input.
|
||||
await page.keyboard.press("Shift");
|
||||
await button.focus();
|
||||
await expect(button).toBeFocused();
|
||||
expect(await matchesFocusVisible(button)).toBe(true);
|
||||
await expect(button).toHaveCSS("outline-style", "solid");
|
||||
await expect(button).toHaveCSS("outline-width", "2px");
|
||||
// Header rows clip overflow; an outset ring would lose its top and bottom edges.
|
||||
await expect(button).toHaveCSS("outline-offset", "-2px");
|
||||
}
|
||||
|
||||
async function expectNoPointerFocusRing(page: Page, testId: string): Promise<void> {
|
||||
const button = visible(page, testId);
|
||||
await button.blur();
|
||||
// Toggle twice so the layout ends where it started.
|
||||
await button.click();
|
||||
await button.click();
|
||||
await expect(button).toBeFocused();
|
||||
expect(await matchesFocusVisible(button)).toBe(false);
|
||||
await expect(button).toHaveCSS("outline-style", "none");
|
||||
}
|
||||
|
||||
test.describe("Icon button focus ring", () => {
|
||||
test("header icon buttons show the focus ring for keyboard focus only", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "icon-button-focus-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
for (const testId of HEADER_ICON_BUTTONS) {
|
||||
await expectKeyboardFocusRing(page, testId);
|
||||
}
|
||||
await expectNoPointerFocusRing(page, "workspace-explorer-toggle");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -125,12 +125,15 @@ test.describe("Tab creation", () => {
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
// The workspace opens on a New tab of its own; count from the tab the shortcut adds.
|
||||
await expect(newTabs.first()).toBeVisible();
|
||||
const countBefore = await newTabs.count();
|
||||
const openedCount = await newTabs.count();
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(openedCount + 1);
|
||||
const countBefore = openedCount + 1;
|
||||
const draftCount = await countTabsOfKind(page, "draft");
|
||||
|
||||
await test.step("opening the plus menu leaves the current tabs intact", async () => {
|
||||
|
||||
@@ -160,6 +160,22 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+N opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await page.goBack();
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page, "Control+N");
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
test,
|
||||
expectStillBrowsing,
|
||||
installFromPage,
|
||||
installListingFromRow,
|
||||
openDirectory,
|
||||
openFromInstalledTab,
|
||||
openListingPage,
|
||||
uninstallFromPage,
|
||||
} from "../support/helpers/plugin-directory";
|
||||
|
||||
test("installs a listed plugin from Browse and uninstalls it from either of its pages", async ({
|
||||
page,
|
||||
directoryHost,
|
||||
}) => {
|
||||
await openDirectory(page, directoryHost);
|
||||
await installListingFromRow(page);
|
||||
await expectStillBrowsing(page);
|
||||
await openListingPage(page);
|
||||
await uninstallFromPage(page);
|
||||
await installFromPage(page);
|
||||
await openFromInstalledTab(page);
|
||||
await uninstallFromPage(page);
|
||||
});
|
||||
@@ -14,6 +14,44 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import {
|
||||
backToPlugins,
|
||||
clearSearch,
|
||||
closePluginLogs,
|
||||
expectGalleryMenuWithoutSourceInstall,
|
||||
expectPluginActionPending,
|
||||
expectPluginFailed,
|
||||
expectPluginHealthy,
|
||||
expectPluginOff,
|
||||
expectPluginOn,
|
||||
expectPluginRemoved,
|
||||
expectPluginsOff,
|
||||
expectPluginsOn,
|
||||
expectPluginsRoute,
|
||||
expectSourceInstallFailed,
|
||||
expectSourceInstallTurnsPluginsOn,
|
||||
expectSourceInstalled,
|
||||
expectToast,
|
||||
expectUpdateAvailable,
|
||||
installFromSource,
|
||||
openPluginLogs,
|
||||
openPluginPage,
|
||||
openPluginPageActions,
|
||||
openSourceInstall,
|
||||
pluginRow,
|
||||
reloadPluginFromPage,
|
||||
removePluginFromPage,
|
||||
searchInstalled,
|
||||
selectInstalledTab,
|
||||
selectPluginPageAction,
|
||||
submitSourceInstall,
|
||||
turnOffPluginsFromMenu,
|
||||
turnOnPluginsFromAlert,
|
||||
turnOnPluginsFromMenu,
|
||||
turnPluginOff,
|
||||
turnPluginOn,
|
||||
updatePlugin,
|
||||
} from "../support/helpers/plugin-gallery";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openCompactSettings,
|
||||
@@ -170,22 +208,12 @@ async function openCompactPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Installed/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
|
||||
await openPluginActions(page, pluginId);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
async function openPluginPageFromSettings(page: Page, name: string): Promise<void> {
|
||||
await openPluginSettings(page);
|
||||
await openPluginPage(page, name);
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
@@ -238,11 +266,6 @@ async function capturePluginInstallForm(
|
||||
});
|
||||
}
|
||||
|
||||
async function installPlugin(page: Page, source: string): Promise<void> {
|
||||
await page.getByLabel("Plugin source").fill(source);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
}
|
||||
|
||||
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
const requestedPage = page
|
||||
.context()
|
||||
@@ -266,6 +289,17 @@ async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function commitAll(repository: string, message: string): void {
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", message], { cwd: repository, stdio: "ignore" });
|
||||
}
|
||||
|
||||
/** A new commit on the remote is an update for a plugin installed from that Git repository. */
|
||||
async function publishGitPluginChange(repository: string, title: string): Promise<void> {
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource(title));
|
||||
commitAll(repository, title);
|
||||
}
|
||||
|
||||
async function createGitPluginRepository(root: string): Promise<string> {
|
||||
const repository = path.join(root, "repository");
|
||||
await mkdir(repository);
|
||||
@@ -359,14 +393,12 @@ async function createGatedBuildRepository(
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
await client.installPluginSource({ source: directory });
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
|
||||
await client.reloadPlugin("failed-preview-plugin");
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
@@ -378,12 +410,15 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await toggleRemoveAndReinstallLocalPlugin(page, directory);
|
||||
});
|
||||
|
||||
test("installs a Git source after a failed source remains editable", async ({
|
||||
test("installs a Git source after a failed source remains editable, then updates it", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
const source = await prepareCompactGitInstall(page, pluginEnvironment);
|
||||
await retryGitInstallAndInspectRows(page, source, testInfo);
|
||||
await updateGitPlugin(page, source.repository);
|
||||
await expectCompactPluginMenuFits(page, testInfo);
|
||||
await failUpdateFromPluginPage(page, source.repository);
|
||||
});
|
||||
|
||||
for (const sourceSupport of [undefined, false]) {
|
||||
@@ -400,7 +435,7 @@ for (const viewport of [
|
||||
{ width: 1280, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
|
||||
test(`installs an npm source and manages it at ${viewport.width}px`, async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
@@ -415,18 +450,19 @@ async function openNpmPluginSettings(page: Page, width: number) {
|
||||
else await openPluginSettings(page);
|
||||
}
|
||||
|
||||
async function expectSourceHierarchy(page: Page, description: string, source: string) {
|
||||
const descriptionText = page.getByText(description, { exact: false });
|
||||
const sourceText = page.getByText(source, { exact: false });
|
||||
const descriptionSize = await descriptionText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
/** The row's description reads before where the plugin was installed from. */
|
||||
async function expectSourceHierarchy(
|
||||
page: Page,
|
||||
pluginId: string,
|
||||
description: string,
|
||||
source: string,
|
||||
) {
|
||||
const row = pluginRow(page, pluginId);
|
||||
await expect(row.getByText(description, { exact: true })).toHaveCSS(
|
||||
"color",
|
||||
"rgb(113, 113, 122)",
|
||||
);
|
||||
const sourceSize = await sourceText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
);
|
||||
expect(sourceSize).toBeLessThan(descriptionSize);
|
||||
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
|
||||
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
|
||||
await expect(row.getByText(source, { exact: true })).toHaveCSS("color", "rgb(161, 161, 170)");
|
||||
}
|
||||
|
||||
async function installLocalPluginWithStatusExamples(
|
||||
@@ -457,36 +493,51 @@ async function installLocalPluginWithStatusExamples(
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
|
||||
await installFailedPlugin(client, failedDirectory);
|
||||
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await expectPluginSourceDocsOpen(page);
|
||||
await expectPluginFailed(page, "failed-preview-plugin");
|
||||
|
||||
// Plugins off on the host: the Installed tab says so and turns them back on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
|
||||
await installFailedPlugin(page, client, failedDirectory);
|
||||
await page.getByLabel("Plugin source").fill(directory);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expectPluginsOff(page);
|
||||
await turnOnPluginsFromAlert(page);
|
||||
await turnOffPluginsFromMenu(page);
|
||||
await turnOnPluginsFromMenu(page);
|
||||
|
||||
await openSourceInstall(page);
|
||||
await expectPluginSourceDocsOpen(page);
|
||||
await submitSourceInstall(page, directory);
|
||||
await expectSourceInstalled(page, "e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await expectPluginHealthy(page, "e2e-plugin");
|
||||
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Keeps optional previews off until this plugin is enabled"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
|
||||
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
await expectPluginOff(page, "disabled-preview-plugin-with-a-long-name");
|
||||
await expectPluginFailed(page, "failed-preview-plugin");
|
||||
|
||||
await searchInstalled(page, "needs attention");
|
||||
await expect(pluginRow(page, "failed-preview-plugin")).toBeVisible();
|
||||
await expect(pluginRow(page, "e2e-plugin")).toHaveCount(0);
|
||||
await clearSearch(page);
|
||||
await expect(pluginRow(page, "e2e-plugin")).toBeVisible();
|
||||
await capturePluginInstallForm(page, testInfo, "wide");
|
||||
await openPluginActions(page, "e2e-plugin");
|
||||
|
||||
await openPluginPage(page, "e2e-plugin");
|
||||
await openPluginPageActions(page, "e2e-plugin");
|
||||
await capturePluginInstallForm(page, testInfo, "wide-menu");
|
||||
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await openPluginLogs(page, "e2e-plugin");
|
||||
await closePluginLogs(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
return directory;
|
||||
@@ -497,13 +548,13 @@ async function reloadAndRecoverLocalPlugin(
|
||||
client: PluginEnvironment["client"],
|
||||
directory: string,
|
||||
) {
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
@@ -511,8 +562,8 @@ async function reloadAndRecoverLocalPlugin(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
@@ -522,39 +573,39 @@ async function reloadAndRecoverLocalPlugin(
|
||||
await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await selectPluginPageAction(page, "e2e-plugin", "Reload");
|
||||
await expectToast(page, "Request failed");
|
||||
await backToPlugins(page, "e2e-plugin");
|
||||
await expectPluginFailed(page, "e2e-plugin");
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await reloadPluginFromPage(page, "e2e-plugin");
|
||||
await backToPlugins(page, "e2e-plugin");
|
||||
await expectPluginHealthy(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
}
|
||||
|
||||
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await turnPluginOff(page, "e2e-plugin");
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await turnPluginOn(page, "e2e-plugin");
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "e2e-plugin", "Remove");
|
||||
await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
|
||||
await openPluginPageFromSettings(page, "e2e-plugin");
|
||||
await removePluginFromPage(page, "e2e-plugin");
|
||||
await expectPluginsRoute(page);
|
||||
await expectPluginRemoved(page, "e2e-plugin");
|
||||
|
||||
await installPlugin(page, directory);
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await installFromSource(page, directory, "e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
}
|
||||
@@ -583,10 +634,8 @@ async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnv
|
||||
await client.disablePlugin("compact-disabled-plugin");
|
||||
await gotoAppShell(page);
|
||||
await openCompactPluginSettings(page);
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await expectPluginsOff(page);
|
||||
|
||||
return { repository, missingSource };
|
||||
}
|
||||
@@ -598,24 +647,58 @@ async function retryGitInstallAndInspectRows(
|
||||
) {
|
||||
const { repository, missingSource } = source;
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
await openSourceInstall(page);
|
||||
// Plugins are off, so the confirmation says installing turns them on.
|
||||
await expectSourceInstallTurnsPluginsOn(page);
|
||||
await submitSourceInstall(page, missingSource);
|
||||
await expectSourceInstallFailed(
|
||||
page,
|
||||
missingSource,
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
|
||||
await installPlugin(page, pathToFileURL(repository).href);
|
||||
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
|
||||
await submitSourceInstall(page, pathToFileURL(repository).href);
|
||||
await expectSourceInstalled(page, "git-e2e-plugin");
|
||||
await expectPluginsOn(page);
|
||||
await expectPluginOn(page, "git-e2e-plugin");
|
||||
await expect(
|
||||
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("");
|
||||
await expectPluginOff(page, "compact-disabled-plugin");
|
||||
await capturePluginInstallForm(page, testInfo, "compact-success");
|
||||
await openPluginActions(page, "git-e2e-plugin");
|
||||
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
|
||||
}
|
||||
|
||||
async function updateGitPlugin(page: Page, repository: string) {
|
||||
await publishGitPluginChange(repository, "Git plugin v2");
|
||||
// Updates are checked when Plugins opens.
|
||||
await page.reload();
|
||||
await expectUpdateAvailable(page, "git-e2e-plugin");
|
||||
await updatePlugin(page, "git-e2e-plugin");
|
||||
await expectPluginOn(page, "git-e2e-plugin");
|
||||
}
|
||||
|
||||
/** An update that fails on the plugin's page says why there, and offers Retry. */
|
||||
async function failUpdateFromPluginPage(page: Page, repository: string) {
|
||||
await publishGitPluginChange(repository, "Git plugin v3");
|
||||
await page.reload();
|
||||
const update = page.getByTestId("plugin-update").filter({ visible: true });
|
||||
await expect(update).toHaveText("Update");
|
||||
// Rewriting the commit the page checked leaves its update pointing at a commit the remote no
|
||||
// longer serves. The next check offers the rewritten commit.
|
||||
execFileSync("git", ["commit", "--amend", "-m", "Git plugin v3, amended"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
await update.click();
|
||||
await expect(update).toHaveText("Retry");
|
||||
await expect(page.getByText(/Git command failed/)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactPluginMenuFits(page: Page, testInfo: TestInfo) {
|
||||
await openPluginPage(page, "git-e2e-plugin");
|
||||
await openPluginPageActions(page, "git-e2e-plugin");
|
||||
const removeAction = page.getByRole("menuitem", { name: "Uninstall", exact: true });
|
||||
await expect(removeAction).toBeInViewport({ ratio: 1 });
|
||||
await waitForSettledPosition(removeAction);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-menu");
|
||||
@@ -648,12 +731,11 @@ async function prepareLegacyPluginHost(
|
||||
|
||||
async function verifyLegacyPluginManagement(page: Page, directory: string) {
|
||||
await expect(
|
||||
page.getByText("Update this host to install plugins", { exact: true }),
|
||||
pluginRow(page, "legacy-source-plugin").getByText(directory, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(directory, { exact: true })).toBeVisible();
|
||||
await selectPluginAction(page, "legacy-source-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
|
||||
await expectGalleryMenuWithoutSourceInstall(page);
|
||||
await openPluginPage(page, "legacy-source-plugin");
|
||||
await reloadPluginFromPage(page, "legacy-source-plugin");
|
||||
}
|
||||
|
||||
async function prepareNpmPluginHost(
|
||||
@@ -667,23 +749,22 @@ async function prepareNpmPluginHost(
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await gotoAppShell(page);
|
||||
await openNpmPluginSettings(page, viewport.width);
|
||||
await selectInstalledTab(page);
|
||||
}
|
||||
|
||||
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
|
||||
await installPlugin(page, "npm:missing-plugin");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
|
||||
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
|
||||
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toBeVisible();
|
||||
await openSourceInstall(page);
|
||||
await submitSourceInstall(page, "npm:missing-plugin");
|
||||
await expectSourceInstallFailed(page, "npm:missing-plugin", "404");
|
||||
await submitSourceInstall(page, "npm:@paseo-fixture/review@^2.0.0");
|
||||
await expectSourceInstalled(page, "npm-review");
|
||||
await expectPluginOn(page, "npm-review");
|
||||
await expectSourceHierarchy(
|
||||
page,
|
||||
"npm-review",
|
||||
"Installed from the npm fixture registry",
|
||||
"npm:@paseo-fixture/review · 2.0.0",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Installed from the npm fixture registry", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`npm-plugin-${width}.png`),
|
||||
animations: "disabled",
|
||||
@@ -691,12 +772,11 @@ async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo:
|
||||
}
|
||||
|
||||
async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await selectPluginAction(page, "npm-review", "Reload");
|
||||
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "npm-review", "Remove");
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
await openPluginPage(page, "npm-review");
|
||||
await reloadPluginFromPage(page, "npm-review");
|
||||
await removePluginFromPage(page, "npm-review");
|
||||
await expectPluginsRoute(page);
|
||||
await expectPluginRemoved(page, "npm-review");
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
@@ -793,19 +873,6 @@ async function holdPluginLifecycleQueue(
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
@@ -836,39 +903,36 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
// Installed before Plugins opens, so it opens on the Installed tab.
|
||||
await openPluginSettings(page);
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await openPluginPage(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await selectPluginPageAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
// Pending: the plugin's controls stay disabled, so a second action cannot be submitted.
|
||||
await expectPluginActionPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
// Past the former deadline the reload is still pending; a request timeout would have settled
|
||||
// it and released the switch.
|
||||
await expectPluginActionPending(page, "e2e-plugin");
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
// The daemon's answer only arrives once the plugin really started again.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await expectToast(page, "Reloaded e2e-plugin");
|
||||
await expectPluginOn(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
expectSettingsFollowPluginSwitch,
|
||||
expectSettingsHidden,
|
||||
expectSettingsTab,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -52,35 +53,41 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
|
||||
test("Back to plugins returns from a plugin page", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
test("plugin pages open from their row and show a tab per settings screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
await test.step("the row opens the plugin's page", async () => {
|
||||
await openTabbedSettings(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await test.step("the page's menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
await test.step("turning a plugin off removes its settings from its page", async () => {
|
||||
await expectSettingsFollowPluginSwitch(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
test("a settings screen link opens its tab, unavailable while the plugin is off", async ({
|
||||
page,
|
||||
settingsPlugin,
|
||||
}) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
await settingsPlugin.disablePlugin("settings-tabs");
|
||||
await expectSettingsUnavailable(page);
|
||||
await settingsPlugin.enablePlugin("settings-tabs");
|
||||
await expectSettingsTab(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
@@ -95,7 +102,7 @@ test.describe("compact plugin settings", () => {
|
||||
await hideMetadata(page);
|
||||
await returnAndReopenSettings(page);
|
||||
await settingsPlugin.disablePlugin("settings-example");
|
||||
await expectSettingsUnavailable(page);
|
||||
await expectSettingsHidden(page);
|
||||
await settingsPlugin.enablePlugin("settings-example");
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { contrastRatio } from "../../src/utils/color";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
for (const [id, name, overrides] of [
|
||||
["solarized-dark", "Solarized Dark", {}],
|
||||
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
|
||||
]) {
|
||||
plugin.addTheme({ id, name, appearance: "dark", colors: {
|
||||
background: "#002b36", foreground: "#839496", raised: "#073642",
|
||||
control: "#073642", border: "#073642", accent: "#268bd2",
|
||||
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
|
||||
}});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
const LATTE_RING = "rgb(156, 160, 176)";
|
||||
|
||||
async function selectTheme(page: Page, name: string) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
const theme = page.getByRole("radio", { name, exact: true });
|
||||
await expect(theme).toBeVisible({ timeout: 30_000 });
|
||||
await theme.click();
|
||||
await expect(theme).toBeChecked();
|
||||
}
|
||||
|
||||
async function expectTimestampContrast(row: Locator) {
|
||||
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(timestamp).toBeVisible();
|
||||
const hex = (rgb: string) =>
|
||||
`#${rgb
|
||||
.match(/\d+/g)!
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
|
||||
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
|
||||
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const selected = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-selected-",
|
||||
title: "Selected theme workspace",
|
||||
});
|
||||
const hovered = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-hovered-",
|
||||
title: "Hovered theme workspace",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await selectTheme(page, "Solarized Dark");
|
||||
await openAgentRoute(page, selected);
|
||||
const selectedRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
|
||||
);
|
||||
const hoveredRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
|
||||
);
|
||||
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
|
||||
await expectTimestampContrast(selectedRow);
|
||||
await hoveredRow.hover();
|
||||
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
|
||||
await expectTimestampContrast(hoveredRow);
|
||||
|
||||
await selectTheme(page, "Solarized Dark Custom");
|
||||
await openAgentRoute(page, selected);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
|
||||
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(221, 238, 255)",
|
||||
);
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close();
|
||||
await selected.cleanup();
|
||||
await hovered.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
|
||||
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -6,20 +7,31 @@ import {
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
captureCompactSidebarReturn,
|
||||
openSettingsFromCompactSidebar,
|
||||
captureCompactSidebarContext,
|
||||
expectCompactSidebarOpen,
|
||||
closeCompactSidebar,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
changeSettingsSyntaxTheme,
|
||||
revealSettingsSyntaxTheme,
|
||||
expectSettingsSyntaxColorChanged,
|
||||
changeSettingsTypography,
|
||||
normalizeSettingsFontSizes,
|
||||
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
@@ -84,13 +96,32 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
|
||||
await changeSettingsSyntaxTheme(page, "Dracula");
|
||||
await expectSettingsDetailScrollOffset(page, offset);
|
||||
await expectSettingsSyntaxColorChanged(page, keywordColor);
|
||||
await changeSettingsTypography(page);
|
||||
});
|
||||
|
||||
test("shows normalized font sizes after committing out-of-range and empty values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await normalizeSettingsFontSizes(page);
|
||||
});
|
||||
|
||||
test("keeps the sidebar context through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
@@ -350,6 +381,87 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("returning from Settings preserves the open sidebar from the first paint", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
|
||||
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
const expectContextPreserved = await captureCompactSidebarContext(page);
|
||||
const openingFrames = await captureCompactSidebarReturn(page, () =>
|
||||
openSettingsFromCompactSidebar(page),
|
||||
);
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-opening-frames.json"),
|
||||
JSON.stringify(openingFrames, null, 2),
|
||||
);
|
||||
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
|
||||
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
|
||||
true,
|
||||
);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-return-frames.json"),
|
||||
JSON.stringify(frames, null, 2),
|
||||
);
|
||||
await testInfo.attach("sidebar-return-frames", {
|
||||
body: JSON.stringify(frames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
|
||||
expect(workspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
const firstX = workspaceFrames[0]!.closeX;
|
||||
expect(
|
||||
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
|
||||
).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectContextPreserved();
|
||||
await openSettingsFromCompactSidebar(page);
|
||||
const backFrames = await captureCompactSidebarReturn(page, async () => {
|
||||
await page.goBack();
|
||||
});
|
||||
await writeFile(
|
||||
testInfo.outputPath("browser-back-frames.json"),
|
||||
JSON.stringify(backFrames, null, 2),
|
||||
);
|
||||
await testInfo.attach("browser-back-frames", {
|
||||
body: JSON.stringify(backFrames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const backWorkspaceFrames = backFrames.filter(
|
||||
(frame) => !frame.pathname.startsWith("/settings"),
|
||||
);
|
||||
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await closeCompactSidebar(page);
|
||||
});
|
||||
|
||||
test("widening during Settings discards the mobile sidebar return", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await closeCompactSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
@@ -377,6 +489,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await closeCompactSidebar(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
|
||||
@@ -146,7 +146,9 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||
test("pins and unpins across sidebar collapse and keeps project creation reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
@@ -169,11 +171,27 @@ test.describe("Pin workspace shortcut", () => {
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await test.step("unpins the active workspace while the Pinned section is collapsed", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(3);
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(4);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(5);
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
});
|
||||
@@ -203,27 +221,6 @@ test.describe("Pin workspace shortcut", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
function workspaceRenameInputTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
@@ -25,40 +30,87 @@ async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
}
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
/** Lets the app render a server message the gate just forwarded. */
|
||||
async function waitForTwoFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
});
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
/** Sets the title from a second daemon client and waits until this page has rendered it. */
|
||||
async function renameFromAnotherClient(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
}
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
async function rowBox(page: Page, workspaceId: string): Promise<Box> {
|
||||
const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox();
|
||||
if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`);
|
||||
return box;
|
||||
}
|
||||
|
||||
// In Model B the workspace title is its identity: renaming sets a custom title
|
||||
// layered over the derived branch/directory name, and reconciliation never
|
||||
// touches it. The sidebar row shows the title verbatim — no branch mutation.
|
||||
test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
|
||||
test("renaming in place sets a custom title without moving the row, and survives reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
|
||||
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const restingBox = await rowBox(page, workspace.workspaceId);
|
||||
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue("main");
|
||||
await expect(
|
||||
page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")),
|
||||
).toHaveCount(0);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
const customTitle = "Payments Refactor";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
// The whole title is selected, so typing replaces it.
|
||||
await page.keyboard.type(customTitle);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
// The title is shown exactly as typed — not slugified into a branch name.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
customTitle,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
// The custom title is backing metadata on the workspace: a full reload
|
||||
// re-resolves the descriptor from persistence and must not lose it. This
|
||||
@@ -73,6 +125,259 @@ test.describe("Sidebar workspace rename", () => {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Discarded title");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("main");
|
||||
await expect(row).not.toContainText("Discarded title");
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
// Clicking inside the field places the caret; it does not press the row underneath.
|
||||
await input.click();
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Saved on blur");
|
||||
// Click empty space in the main pane, away from any control.
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(row).toContainText("Saved on blur");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the submitted title stays on screen until the renamed workspace arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Held title");
|
||||
// The daemon answers the rename before it pushes the renamed workspace.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Held title");
|
||||
await expect(input).not.toBeEditable();
|
||||
await expect(row).not.toContainText("main");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Held title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await input.fill("Submitted title");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting the title another client just set closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
|
||||
await input.fill("External title");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
|
||||
// The row is a row again: its menu opens and renames.
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// Another client's rename lands just before ours. The page receives, in daemon order: that
|
||||
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
|
||||
// Releases the oldest held update: the other client's. Ours stays held behind it.
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// The daemon builds each update from its latest record, so a rename that lands after ours
|
||||
// is accepted can be published in its place. Our update stays held: the page never sees it.
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Later title");
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 800, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await input.fill("Modal Title");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
"Modal Title",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { gate } from "../support/helpers/context-window";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -32,7 +34,13 @@ import {
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
pickUsageHost,
|
||||
usageModalHost,
|
||||
dragUsageCardAbove,
|
||||
recordUsageCardPositionsAfterRelease,
|
||||
expectUsageCardOrder,
|
||||
expectUsageOptionsFitContent,
|
||||
setUsageCardOn,
|
||||
showUsageAs,
|
||||
refreshAllUsage,
|
||||
togglePin,
|
||||
@@ -79,16 +87,17 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
@@ -102,6 +111,14 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function accountReport(sourceId: string, account: string, usedPct: number): UsageReportEntry {
|
||||
return {
|
||||
...weeklyReport(sourceId, usedPct),
|
||||
id: `${sourceId}:${account}`,
|
||||
account: { label: account },
|
||||
};
|
||||
}
|
||||
|
||||
function installReversedUsageRefresh(page: Page) {
|
||||
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
||||
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
||||
@@ -114,15 +131,15 @@ 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 expectPinnedUsage(page, ["12% Weekly", "31% 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",
|
||||
"opencode plan",
|
||||
]);
|
||||
await expectPinnedUsage(page, summary);
|
||||
}
|
||||
@@ -137,7 +154,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
test("opens from the sidebar on the host's reports, with no host named for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -177,7 +194,7 @@ test.describe("usage modal", () => {
|
||||
|
||||
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 expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
@@ -194,15 +211,67 @@ test.describe("usage modal", () => {
|
||||
}) => {
|
||||
const usage = await installReversedUsageRefresh(page);
|
||||
await openUsageSummaryAtWidth(page, width);
|
||||
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
||||
// Reports arrive opencode first; cards and the summary stay alphabetical.
|
||||
await expectUsageProviderOrder(page, ["12% Weekly", "31% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
||||
|
||||
await refreshUsageAndWaitForReports(page, usage);
|
||||
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
||||
await expectUsageProviderOrder(page, ["22% Weekly", "41% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps a card turned off and a dragged order after a reload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
accountReport("beta", "work", 12),
|
||||
weeklyReport("alpha", 31),
|
||||
accountReport("beta", "home", 20),
|
||||
],
|
||||
],
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
// Three windows leave no room for labels in the summary, so it reads percents.
|
||||
await expectPinnedUsage(page, ["31%", "20%", "12%"]);
|
||||
await openUsageFromItem(page);
|
||||
await expectUsageCardOrder(page, ["alpha:a", "beta:home", "beta:work"]);
|
||||
|
||||
// Without the pointer over the card, keyboard focus on its handle shows the handle.
|
||||
await page.mouse.move(0, 0);
|
||||
const alphaHandle = page
|
||||
.getByTestId("usage-report-alpha:a")
|
||||
.getByRole("button", { name: "Reorder alpha plan", exact: true });
|
||||
await expect(alphaHandle).toHaveCSS("opacity", "0");
|
||||
await alphaHandle.focus();
|
||||
await expect(alphaHandle).toHaveCSS("opacity", "1");
|
||||
await alphaHandle.blur();
|
||||
|
||||
// The off card collapses to its header, which still names the account.
|
||||
await setUsageCardOn(page, "beta plan (work)", false);
|
||||
const work = page.getByTestId("usage-report-beta:work");
|
||||
await expect(work.getByText("12% used")).toHaveCount(0);
|
||||
await expect(work.getByText("work", { exact: true })).toBeVisible();
|
||||
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
|
||||
|
||||
const stopRecording = await recordUsageCardPositionsAfterRelease(page);
|
||||
await dragUsageCardAbove(page, "beta:home", "alpha:a");
|
||||
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
|
||||
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
|
||||
// The cards are in their new slots from the first frame after the drop.
|
||||
const framesAfterDrop = await stopRecording();
|
||||
const settled = framesAfterDrop.at(-1);
|
||||
expect(settled).toBeDefined();
|
||||
expect(framesAfterDrop.filter((frame) => frame !== settled)).toEqual([]);
|
||||
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
|
||||
await expect(page.getByTestId("usage-report-beta:work").getByText("12% used")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
@@ -314,9 +383,11 @@ test.describe("usage modal", () => {
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
// The second host's reports wait until the test lets them through, like a slow host.
|
||||
const secondaryReports = gate();
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
lists: [{ stream: [secondaryReports.promise, weeklyReport("beta", 12)] }],
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
@@ -330,12 +401,15 @@ test.describe("usage modal", () => {
|
||||
await openUsageFromItem(page);
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await expect(usageModalHost(page)).toHaveText("localhost");
|
||||
await pickUsageHost(page, secondary.serverId);
|
||||
// Until its reports arrive, the picked host loads; it never shows the first host's cards.
|
||||
const secondaryBody = page.getByTestId(`usage-host-${secondary.serverId}`);
|
||||
await expect(secondaryBody.getByText("Loading usage...")).toBeVisible();
|
||||
await expect(page.getByTestId("usage-modal-body").getByText("31% used")).toHaveCount(0);
|
||||
await expect(usageModalHost(page)).toHaveText("Secondary box");
|
||||
secondaryReports.open();
|
||||
await expect(secondaryBody.getByText("12% used")).toBeVisible({ timeout: 30_000 });
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
@@ -354,6 +428,51 @@ test.describe("usage modal", () => {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("on a phone, picking a host in Settings keeps Usage open on that host", async ({ page }) => {
|
||||
test.setTimeout(420_000);
|
||||
const longName = "build-box-eu-west-gpu-runner-02.internal";
|
||||
const secondary = await startIsolatedHostDaemon(
|
||||
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
});
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: longName,
|
||||
port: secondary.port,
|
||||
});
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromItem(page);
|
||||
|
||||
await pickUsageHost(page, secondary.serverId);
|
||||
|
||||
// The sheet stays up once Settings has closed over it, showing the picked host's cards.
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(usageModalHost(page)).toHaveText(longName);
|
||||
// The long name truncates under the title; the title itself reads in full.
|
||||
const title = page.locator('[data-testid="usage-modal-title"]:visible');
|
||||
await expect(title).toHaveText("Usage");
|
||||
expect(await title.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
const host = usageModalHost(page);
|
||||
expect(await host.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
await qaScreenshot(page, "usage-phone-picked-long-host");
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const loginWindows: UsageReportEntry["report"] = {
|
||||
@@ -628,6 +747,15 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -639,6 +767,8 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -647,6 +777,9 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
openUsageFromItemWithPressFeedback,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
@@ -197,7 +198,7 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await openUsageFromItem(page);
|
||||
await openUsageFromItemWithPressFeedback(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
@@ -317,10 +318,11 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: no host filter, as on History.
|
||||
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
// One host: Settings has no Host row, and the title no host under it.
|
||||
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-options-host")).toHaveCount(0);
|
||||
await closeUsageOptions(page);
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// A long user message paints capped, with its fade and Show more, in the frame it mounts in. These
|
||||
// tests sample every painted frame from before the chat opens and require each long message to
|
||||
// show one state, the settled one, for its whole life on screen.
|
||||
|
||||
interface PreviewFrame {
|
||||
height: number;
|
||||
fade: boolean;
|
||||
control: string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__previewFrames?: Record<string, PreviewFrame[]>;
|
||||
}
|
||||
}
|
||||
|
||||
function longPrompt(name: string) {
|
||||
return Array.from({ length: 40 }, (_, line) => `${name} line ${line + 1}`).join("\n");
|
||||
}
|
||||
|
||||
async function recordPreviewFrames(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const frames: Record<string, PreviewFrame[]> = {};
|
||||
window.__previewFrames = frames;
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="user-message"]')) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.height === 0) continue;
|
||||
const text = row.querySelector('[data-message-text="true"]');
|
||||
if (!text) continue;
|
||||
const name = (text.textContent ?? "").split(" line ")[0];
|
||||
// The preview is the ancestor that carries the height cap; the fade paints inside it.
|
||||
let preview = text.parentElement;
|
||||
while (preview && preview !== row && getComputedStyle(preview).maxHeight === "none") {
|
||||
preview = preview.parentElement;
|
||||
}
|
||||
const control = [...row.querySelectorAll("*")].find(
|
||||
(node) => node.children.length === 0 && /^Show (more|less)$/.test(node.textContent ?? ""),
|
||||
);
|
||||
const frame = {
|
||||
height: Math.round(rect.height),
|
||||
fade: preview !== null && preview !== row && preview.querySelector("svg") !== null,
|
||||
control: control?.textContent ?? null,
|
||||
};
|
||||
const seen = (frames[name] ??= []);
|
||||
if (JSON.stringify(seen.at(-1)) !== JSON.stringify(frame)) seen.push(frame);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPaintedCollapsedFromFirstFrame(page: Page, name: string) {
|
||||
const message = page.getByTestId("user-message").filter({ hasText: `${name} line 1` });
|
||||
await expect(message.getByRole("button", { name: "Show more" })).toBeVisible();
|
||||
// Let any late measurement land before reading the frames.
|
||||
await page.waitForTimeout(500);
|
||||
const frames = await page.evaluate((key) => window.__previewFrames?.[key] ?? [], name);
|
||||
expect(frames).toEqual([{ height: expect.any(Number), fade: true, control: "Show more" }]);
|
||||
}
|
||||
|
||||
test.describe("long user message preview", () => {
|
||||
test("opening a chat paints its long message collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-open-",
|
||||
title: "Preview open",
|
||||
initialPrompt: longPrompt("Opened prompt"),
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
|
||||
await page.reload();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message arriving in an open chat paints collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-arrive-",
|
||||
title: "Preview arrive",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Arrived prompt"));
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Arrived prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message that arrived while its tab was hidden paints collapsed on return", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-hidden-",
|
||||
title: "Preview first",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
title: "Preview second",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await page.getByRole("button", { name: "Preview second", exact: true }).click();
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Hidden prompt"));
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.getByRole("button", { name: "Preview first", exact: true }).click();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Hidden prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
async function observeToastReplacement(page: Page) {
|
||||
const observation = await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Reconnecting to host" })
|
||||
.filter({ hasText: "Reconnecting" })
|
||||
.evaluateHandle((toast) => {
|
||||
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
|
||||
let frame = 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archivedToast,
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
scrollSidebarListToEnd,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
@@ -119,6 +121,41 @@ test.describe("Workspace archive undo", () => {
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("a long list ends just below its last row, and the toast over it moves no row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
|
||||
const fillerProjectIds: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
|
||||
fillers.push(repo);
|
||||
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
|
||||
}
|
||||
const worktree = await seedWorktree(page, "archive-toast-long-list");
|
||||
|
||||
const resting = await scrollSidebarListToEnd(page);
|
||||
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.mouse.move(0, 0);
|
||||
const withToast = await scrollSidebarListToEnd(page);
|
||||
const toastBox = await archivedToast(page).boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
|
||||
|
||||
await outlastUndoWindow(page);
|
||||
await expect(archivedToast(page)).toHaveCount(0);
|
||||
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
|
||||
} finally {
|
||||
for (const projectId of fillerProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -50,73 +50,64 @@ async function readClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
function terminalTab(page: Page, terminalId: string) {
|
||||
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
}
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
|
||||
let terminalId: string | null = null;
|
||||
async function copyTerminalId(page: Page, terminalId: string): Promise<void> {
|
||||
const tab = terminalTab(page, terminalId);
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
await tab.click({ button: "right" });
|
||||
const copy = page.getByRole("menuitem", { name: /^Copy terminal id/ });
|
||||
await expect(copy).toBeVisible({ timeout: 10_000 });
|
||||
await copy.click();
|
||||
}
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
async function renameTerminalTab(page: Page, terminalId: string, title: string): Promise<void> {
|
||||
const tab = terminalTab(page, terminalId);
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
await tab.click({ button: "right" });
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(rename).toBeVisible({ timeout: 10_000 });
|
||||
await rename.click();
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill(title);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByRole("menuitem", {
|
||||
name: /^Copy terminal id/,
|
||||
test("terminal context menu copies its ID and persists a renamed title", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
const createdTerminalId = await waitForCreatedTerminalId(workspace);
|
||||
terminalId = createdTerminalId;
|
||||
|
||||
await test.step("copy the terminal ID from its tab", async () => {
|
||||
await copyTerminalId(page, createdTerminalId);
|
||||
await expect.poll(() => readClipboard(page)).toBe(createdTerminalId);
|
||||
});
|
||||
|
||||
await test.step("rename the same terminal and read back its saved title", async () => {
|
||||
await renameTerminalTab(page, createdTerminalId, "My Renamed Terminal");
|
||||
await expect(terminalTab(page, createdTerminalId)).toContainText("My Renamed Terminal", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(terminalId);
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await input.fill("My Renamed Terminal");
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
await expect
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.poll(() => fetchTerminalTitle(workspace, createdTerminalId))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
test.describe("Updates to agents in other workspaces", () => {
|
||||
let open: SeededWorkspace;
|
||||
let other: SeededWorkspace;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await open?.cleanup();
|
||||
await other?.cleanup();
|
||||
});
|
||||
|
||||
test("do not re-render the open workspace screen", async ({ page }) => {
|
||||
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
|
||||
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
|
||||
const openAgentId = await createMockAgent(open, "Open agent");
|
||||
const otherAgentId = await createMockAgent(other, "Other agent");
|
||||
|
||||
await recordReactRenders(page);
|
||||
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await resetReactRenders(page);
|
||||
|
||||
await other.client.sendAgentMessage(otherAgentId, "update");
|
||||
await other.client.waitForFinish(otherAgentId, 60_000);
|
||||
// The favicon follows every agent on the host, so it renders when the other agent runs and
|
||||
// settles. That confirms the updates reached this page.
|
||||
await expect
|
||||
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -5,27 +5,34 @@ import path from "node:path";
|
||||
import type { Dialog, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
clickArchiveWorkspaceMenuItem,
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
rowArchiveButton,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
} from "../support/helpers/workspace-archive-undo";
|
||||
import {
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
async function seedRiskyWorktree(
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
worktreeDirectory: string,
|
||||
): Promise<void> {
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
|
||||
// The daemon only reports unpushed commits when the branch has a configured
|
||||
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
|
||||
// worktree branch at its current head first so it tracks origin with 0 ahead,
|
||||
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
|
||||
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
|
||||
await writeFile(dirtyFile, "# dirty\n");
|
||||
|
||||
const refreshed = await client.checkoutRefresh(worktreeDirectory);
|
||||
await refreshCheckout(client, worktreeDirectory);
|
||||
}
|
||||
|
||||
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
|
||||
const refreshed = await client.checkoutRefresh(directory);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
|
||||
throw new Error(`Failed to refresh checkout for ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The archive confirmation is a synchronous web `window.confirm()`. The click that
|
||||
// opens it does not resolve until the dialog is answered, so the handler must
|
||||
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
|
||||
// the click waits for an answer that is gated behind that same click.
|
||||
async function clickArchiveAndAnswerWarning(
|
||||
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
|
||||
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
|
||||
// not resolve until it is answered, so answer it from the event.
|
||||
async function answerArchiveWarning(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
answer: "accept" | "dismiss",
|
||||
openArchive: () => Promise<void>,
|
||||
): Promise<Dialog> {
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
|
||||
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
return dialog;
|
||||
});
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
if (!warning) {
|
||||
throw new Error("Expected an archive confirmation dialog, but none was shown.");
|
||||
await openArchive();
|
||||
return await warning;
|
||||
}
|
||||
|
||||
// A clean Paseo worktree's primary git action in the workspace header is Archive.
|
||||
function gitActionsArchiveButton(page: Page) {
|
||||
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
|
||||
}
|
||||
|
||||
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await rowArchiveButton(page, workspaceId).click();
|
||||
}
|
||||
|
||||
interface SessionEnvelope {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
workspaceId?: unknown;
|
||||
cwd?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionEnvelope(message: string): SessionEnvelope | null {
|
||||
try {
|
||||
return JSON.parse(message) as SessionEnvelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce<number>(
|
||||
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== "object") return 0;
|
||||
const record = value as Record<string, unknown>;
|
||||
let count = 0;
|
||||
if (record.id === workspaceId && "workspaceDirectory" in record) {
|
||||
delete record.gitRuntime;
|
||||
record.diffStat = null;
|
||||
count += 1;
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
count += removeGitStatusFromDescriptors(child, workspaceId);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
|
||||
* the app without git status, as the host sends it before its first checkout snapshot of the
|
||||
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
|
||||
* host's checkout status answer for that directory into a git error.
|
||||
*/
|
||||
async function relayDaemon(
|
||||
page: Page,
|
||||
input: { workspaceId: string; failCheckoutStatusFor?: string },
|
||||
) {
|
||||
let descriptorRewrites = 0;
|
||||
let archiveRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const request = parseSessionEnvelope(message)?.message;
|
||||
if (
|
||||
request?.type === "archive_workspace_request" &&
|
||||
request.workspaceId === input.workspaceId
|
||||
) {
|
||||
archiveRequests += 1;
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = parseSessionEnvelope(message);
|
||||
const response = envelope?.message;
|
||||
if (
|
||||
input.failCheckoutStatusFor &&
|
||||
response?.type === "checkout_status_response" &&
|
||||
response.payload?.cwd === input.failCheckoutStatusFor
|
||||
) {
|
||||
response.payload = {
|
||||
...response.payload,
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: null,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
error: { code: "UNKNOWN", message: "git status failed" },
|
||||
};
|
||||
browser.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
if (!envelope || !message.includes(input.workspaceId)) {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
|
||||
browser.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
return {
|
||||
descriptorRewrites: () => descriptorRewrites,
|
||||
archiveRequests: () => archiveRequests,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the app's next checkout status request for `directory` once armed, so a test can act
|
||||
* while the archive waits for the host's answer.
|
||||
*/
|
||||
async function holdCheckoutStatusRequest(page: Page, directory: string) {
|
||||
let armed = false;
|
||||
let held: (() => void) | null = null;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
|
||||
if (
|
||||
armed &&
|
||||
!held &&
|
||||
request?.type === "checkout_status_request" &&
|
||||
request.cwd === directory
|
||||
) {
|
||||
held = () => server.send(message);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
return {
|
||||
arm: () => {
|
||||
armed = true;
|
||||
},
|
||||
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
|
||||
release: () => {
|
||||
if (!held) throw new Error("No checkout status request is held");
|
||||
held();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let client: DaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
async function createWorktree(slug: string) {
|
||||
const project = await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-risk-${Date.now()}`,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
return { ...worktree, project };
|
||||
}
|
||||
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
|
||||
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
|
||||
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
|
||||
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
return entries.some((entry) => entry.id === workspaceId);
|
||||
}
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
expect(firstWarning.type()).toBe("confirm");
|
||||
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(firstWarning.message()).toContain("Uncommitted changes");
|
||||
expect(firstWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
|
||||
const secondWarning = await answerArchiveWarning(page, "accept", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
expect(secondWarning.message()).toContain("Uncommitted changes");
|
||||
expect(secondWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
@@ -143,4 +321,128 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
|
||||
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
|
||||
});
|
||||
|
||||
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-uncached");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
expect(warning.message()).toContain("1 unpushed commit");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a worktree holding only new files warns when the repository hides untracked files", async ({
|
||||
page,
|
||||
}) => {
|
||||
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
|
||||
const worktree = await createWorktree("archive-risk-untracked");
|
||||
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
|
||||
await refreshCheckout(client, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
|
||||
const worktree = await createWorktree("archive-risk-read-failure");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, {
|
||||
workspaceId: worktree.workspaceId,
|
||||
failCheckoutStatusFor: worktree.workspaceDirectory,
|
||||
});
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
await clickRowArchiveButton(page, worktree.workspaceId);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
});
|
||||
|
||||
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
|
||||
// host's checkout status answer is held, then lets the answer through.
|
||||
async function archiveWhileOpeningAnotherWorkspace(
|
||||
page: Page,
|
||||
slug: string,
|
||||
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
|
||||
): Promise<string> {
|
||||
const serverId = getServerId();
|
||||
const worktree = await createWorktree(slug);
|
||||
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
|
||||
|
||||
statusRequest.arm();
|
||||
await startArchive(worktree);
|
||||
await statusRequest.waitForHeld();
|
||||
await workspaceRow(page, worktree.project.workspaceId).click();
|
||||
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
|
||||
statusRequest.release();
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
// The redirect runs before the archive request, so once the host has archived the worktree
|
||||
// any redirect has already happened.
|
||||
await expect
|
||||
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
return openedRoute;
|
||||
}
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a row archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-row",
|
||||
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-git",
|
||||
() => gitActionsArchiveButton(page).click(),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-git-redirect");
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
|
||||
|
||||
await gitActionsArchiveButton(page).click();
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
restoreArchivedWorkspace,
|
||||
@@ -300,9 +299,7 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("restores the workspace before explicitly unarchiving each selected agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
@@ -330,13 +327,11 @@ test.describe("Worktree restore", () => {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, firstAgent.id)).toBeNull();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, secondAgent.id)).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -346,13 +341,8 @@ test.describe("Worktree restore", () => {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
@@ -410,12 +400,7 @@ test.describe("Worktree restore", () => {
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
@@ -506,7 +491,11 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
const screenshotDirectory = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"paseo-branchless-restore-evidence",
|
||||
);
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
# Start in an empty, connected composer with the Claude provider selected.
|
||||
# COMMAND_ROW must name an inserting command, not an immediate client action.
|
||||
env COMMAND_ROW="/advisor, Let Claude consult a stronger model at key moments"
|
||||
env COMMAND_TEXT="/advisor "
|
||||
open sh.paseo.debug
|
||||
wait "editable=true" 60000
|
||||
fill "editable=true" "/"
|
||||
wait "label=\"${COMMAND_ROW}\"" 15000
|
||||
press "label=\"${COMMAND_ROW}\""
|
||||
# Do not type or move the caret: replacement itself must close autocomplete.
|
||||
wait absent "label=\"${COMMAND_ROW}\"" 3000
|
||||
wait "editable=true value=\"${COMMAND_TEXT}\"" 3000
|
||||
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
# Hold past press feedback (130ms) and drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 400
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
# A released drag must not swallow the next tap: navigation closes the sidebar.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
|
||||
@@ -111,11 +111,13 @@ try {
|
||||
"android",
|
||||
"--no-test-ime",
|
||||
);
|
||||
ad("wait", "text", "Plugin source", "5000");
|
||||
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
|
||||
assert(source, "The connected host must support installing plugins from source");
|
||||
ad("press", `@${source.ref}`);
|
||||
// The source field lives in the Install from source sheet, behind the page's menu.
|
||||
ad("wait", 'label="More plugin actions"', "5000");
|
||||
ad("press", 'label="More plugin actions"');
|
||||
ad("wait", 'text="Install from source"', "5000");
|
||||
ad("press", 'text="Install from source"');
|
||||
ad("wait", 'id="plugin-source-input"', "5000");
|
||||
ad("press", 'id="plugin-source-input"');
|
||||
ad("type", "qa-source");
|
||||
const pluginMeasurement = await waitForFocusedInput();
|
||||
// A FormTextInput draws its border and padding on the native input's parent.
|
||||
|
||||
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,83 @@
|
||||
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
|
||||
// Pass a vertical sample line through the card's plain interior, away from text,
|
||||
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
|
||||
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
|
||||
// This checks actual native frames; a browser layout test misses this regression.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , recording, sampleX, start, end] = process.argv;
|
||||
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
|
||||
const { frames } = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"json",
|
||||
recording,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const selectedFrames = frames
|
||||
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
|
||||
.filter(({ time }) => time >= Number(start) && time <= Number(end));
|
||||
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
|
||||
const firstFrame = selectedFrames[0].index;
|
||||
const endFrame = selectedFrames.at(-1).index + 1;
|
||||
const bytesPerFrame = 800;
|
||||
// Bound both output and buffer to the requested frames, even for a long recording.
|
||||
const pixels = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"fatal",
|
||||
"-i",
|
||||
recording,
|
||||
"-vf",
|
||||
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
|
||||
);
|
||||
assert.equal(
|
||||
pixels.length,
|
||||
selectedFrames.length * bytesPerFrame,
|
||||
"Every selected frame must be decoded",
|
||||
);
|
||||
const positions = [];
|
||||
let disappeared = false;
|
||||
for (const [index, { time }] of selectedFrames.entries()) {
|
||||
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
|
||||
const runs = [];
|
||||
let run = [];
|
||||
for (let y = 0; y < 400; y++) {
|
||||
if (column[y * 2] > 250) run.push(y);
|
||||
else {
|
||||
if (run.length > 40) runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
}
|
||||
if (run.length > 40) runs.push(run);
|
||||
runs.sort((a, b) => b.length - a.length);
|
||||
if (runs.length) {
|
||||
const card = runs[0];
|
||||
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
|
||||
} else if (positions.length) disappeared = true;
|
||||
}
|
||||
assert(positions.length > 2, "Capture at least three visible card frames");
|
||||
assert(disappeared, "Recording must include the card disappearing");
|
||||
const centres = positions.map(({ centre }) => centre);
|
||||
const shift = Math.max(...centres) - Math.min(...centres);
|
||||
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
|
||||
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
|
||||
@@ -0,0 +1,27 @@
|
||||
# Android Usage drop regressions
|
||||
|
||||
Use a device you own and fixed reports from an isolated daemon. Keep recordings in `.artifacts/`. Do not upload recordings containing account details.
|
||||
|
||||
## Same-slot drop
|
||||
|
||||
Open Usage with two cards that fit without scrolling. Run:
|
||||
|
||||
```sh
|
||||
ANDROID_SERIAL=emulator-5580 node packages/app/e2e/mobile/usage-drag/android.mjs
|
||||
```
|
||||
|
||||
Set `PASEO_DEVICE_SESSION` to reuse your Agent Device session. The test holds the first handle, moves within its slot, drops it, then swipes the sheet body. It asserts sheet movement while the finger is still down: after release, the sheet can snap back to its starting position.
|
||||
|
||||
## Old-order frame
|
||||
|
||||
Record a drop with Android `screenrecord`. Include the original order, the held arrangement, the release, and at least five seconds after release. Take a reference screenshot before recording. Use fixed labels that stay visible throughout the drop.
|
||||
|
||||
Create a JSON map of label names to screenshot rectangles `[x, y, width, height]`, in physical pixels. Crop each label tightly, without changing text or the pointer. Install `opencv-python-headless` in a local virtual environment under `.artifacts/`, then run:
|
||||
|
||||
```sh
|
||||
.artifacts/frames-venv/bin/python packages/app/e2e/mobile/usage-drag/frames.py \
|
||||
--reference .artifacts/before.png --labels .artifacts/labels.json \
|
||||
.artifacts/drop-up.mp4 .artifacts/drop-down.mp4
|
||||
```
|
||||
|
||||
The check requires recognized labels in the first and last frames and a changed final order. After every label first reaches its final slot, it fails on any frame showing all original slots again. It prints the offending frame numbers for each recording. This exercises the Android compositor; a JavaScript render test cannot establish which intermediate commit reached the screen.
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Open Usage with a fixture whose cards fit without scrolling before running.
|
||||
// A same-slot drop must release the list's recognizer so a body swipe moves the sheet.
|
||||
const serial = process.env.ANDROID_SERIAL;
|
||||
assert.ok(serial, "Set ANDROID_SERIAL to the device owned by this test");
|
||||
const session = process.env.PASEO_DEVICE_SESSION ?? "usage-drag-regression";
|
||||
const artifacts = resolve(".artifacts/usage-drag-regression");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync(
|
||||
"agent-device",
|
||||
[...args, "--platform", "android", "--serial", serial, "--session", session],
|
||||
{ encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], { timeout: 30_000 });
|
||||
}
|
||||
function snapshot() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
function touch(kind, x, y) {
|
||||
adb("shell", "input", "motionevent", kind, String(Math.round(x)), String(Math.round(y)));
|
||||
}
|
||||
try {
|
||||
ad("open", process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug");
|
||||
const nodes = snapshot();
|
||||
writeFileSync(resolve(artifacts, "before.json"), JSON.stringify(nodes));
|
||||
const handle = nodes.find((node) => node.identifier === "usage-card-drag-handle");
|
||||
const list = nodes.find((node) => node.identifier === "usage-list");
|
||||
assert.ok(handle?.rect && list?.rect, "Open Usage with a visible card handle");
|
||||
const x = handle.rect.x + handle.rect.width / 2;
|
||||
const y = handle.rect.y + handle.rect.height / 2;
|
||||
touch("DOWN", x, y);
|
||||
await setTimeout(600);
|
||||
touch("MOVE", x, y + 60);
|
||||
await setTimeout(200);
|
||||
touch("MOVE", x, y + 90);
|
||||
touch("MOVE", x, y + 60);
|
||||
ad("screenshot", resolve(artifacts, "dragging.png"));
|
||||
touch("UP", x, y + 60);
|
||||
await setTimeout(2000);
|
||||
ad("screenshot", resolve(artifacts, "dropped.png"));
|
||||
const swipeX = list.rect.x + list.rect.width / 2;
|
||||
touch("DOWN", swipeX, y + 80);
|
||||
for (let offset = 90; offset <= 500; offset += 10) {
|
||||
touch("MOVE", swipeX, y + offset);
|
||||
}
|
||||
const during = snapshot();
|
||||
writeFileSync(resolve(artifacts, "during.json"), JSON.stringify(during));
|
||||
ad("screenshot", resolve(artifacts, "during.png"));
|
||||
touch("UP", swipeX, y + 500);
|
||||
await setTimeout(1500);
|
||||
const after = snapshot();
|
||||
writeFileSync(resolve(artifacts, "after.json"), JSON.stringify(after));
|
||||
ad("screenshot", resolve(artifacts, "after.png"));
|
||||
const beforeSheet = nodes.find((node) => node.label === "Bottom sheet handle");
|
||||
const afterSheet = during.find((node) => node.label === "Bottom sheet handle");
|
||||
assert.ok(beforeSheet?.rect, "Usage must start as a sheet");
|
||||
assert.ok(
|
||||
!afterSheet || afterSheet.rect.y > beforeSheet.rect.y + 50,
|
||||
"A body swipe after a same-slot drop must move the sheet",
|
||||
);
|
||||
} finally {
|
||||
ad("close");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Check native drop recordings against visible row labels (requires opencv-python-headless).
|
||||
|
||||
Supply a before screenshot and a JSON map of label names to [x, y, width, height].
|
||||
A recording must start in the original order and end in a different order. Once
|
||||
all labels reach their final slots, any frame returning to the original slots fails.
|
||||
Keep the pointer clear of the labels and use fixed fixture data.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--reference", required=True)
|
||||
parser.add_argument("--labels", required=True)
|
||||
parser.add_argument("--top", type=int, default=0)
|
||||
parser.add_argument("--bottom", type=int)
|
||||
parser.add_argument("videos", nargs="+")
|
||||
args = parser.parse_args()
|
||||
reference = cv2.imread(args.reference, cv2.IMREAD_GRAYSCALE)
|
||||
assert reference is not None, "Cannot read reference image"
|
||||
labels = json.loads(Path(args.labels).read_text())
|
||||
results = []
|
||||
for video in args.videos:
|
||||
capture = cv2.VideoCapture(video)
|
||||
frames = []
|
||||
while True:
|
||||
ok, image = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
positions = {}
|
||||
for label, (x, y, width, height) in labels.items():
|
||||
template = reference[y:y + height, x:x + width]
|
||||
matches = cv2.matchTemplate(gray[args.top:args.bottom, x:x + width], template, cv2.TM_CCOEFF_NORMED)
|
||||
_, confidence, _, point = cv2.minMaxLoc(matches)
|
||||
positions[label] = (point[1] + args.top, confidence)
|
||||
frames.append(positions)
|
||||
capture.release()
|
||||
assert frames, f"No frames in {video}"
|
||||
initial, final = frames[0], frames[-1]
|
||||
assert all(confidence >= 0.95 for _, confidence in initial.values()), f"Unrecognized initial labels: {video}"
|
||||
assert all(confidence >= 0.95 for _, confidence in final.values()), f"Unrecognized final labels: {video}"
|
||||
assert sorted(initial, key=lambda key: initial[key][0]) != sorted(final, key=lambda key: final[key][0]), f"Drop did not reorder: {video}"
|
||||
|
||||
def matches_slots(frame, target):
|
||||
return all(confidence >= 0.95 and abs(y - target[label][0]) <= 2
|
||||
for label, (y, confidence) in frame.items())
|
||||
|
||||
reached_final = False
|
||||
wrong = []
|
||||
for number, frame in enumerate(frames, start=1):
|
||||
reached_final = reached_final or matches_slots(frame, final)
|
||||
if reached_final and matches_slots(frame, initial):
|
||||
wrong.append(number)
|
||||
results.append({"recording": video, "oldOrderFrames": wrong, "count": len(wrong)})
|
||||
print(json.dumps(results, indent=2))
|
||||
raise SystemExit(1 if any(result["count"] for result in results) else 0)
|
||||
@@ -1,6 +1,8 @@
|
||||
import { writeFile, rm, rename } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile, rm, rename } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample } from "./helpers/plugin-fixture";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
||||
import { test as base } from "./fixtures";
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
export const test = base.extend<{
|
||||
startup: { release(): Promise<void>; fail(): Promise<void> };
|
||||
promptRejection: void;
|
||||
composerMetadata: { set(patch: Record<string, string>): Promise<void> };
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
@@ -98,6 +101,39 @@ export default function contribute(server) {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
composerMetadata: async ({ page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-metadata-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "composer-metadata", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useComposer } from "@getpaseo/plugin/client";
|
||||
|
||||
function Holder() {
|
||||
globalThis.metadataComposer = useComposer();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerItem({ id: "holder", Component: Holder });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await provide({ set: (patch) => setComposerMetadata(page, patch) });
|
||||
} finally {
|
||||
await client.removePlugin("composer-metadata");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
creation: async ({ page }, provide) => {
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
@@ -126,3 +162,15 @@ export default function contribute(server) {
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
type MetadataComposerGlobal = typeof globalThis & {
|
||||
metadataComposer?: { setMetadata(patch: Record<string, string>): Promise<void> };
|
||||
};
|
||||
|
||||
async function setComposerMetadata(page: Page, patch: Record<string, string>) {
|
||||
await page.waitForFunction(() => (globalThis as MetadataComposerGlobal).metadataComposer);
|
||||
await page.evaluate(
|
||||
(value) => (globalThis as MetadataComposerGlobal).metadataComposer!.setMetadata(value),
|
||||
patch,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
tabs: '[data-testid="command-center-tabs"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function seedSearchableCommandCenter() {
|
||||
});
|
||||
try {
|
||||
const agents = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
@@ -49,11 +49,27 @@ export async function searchCommandCenter(panel: Locator, query: string) {
|
||||
}
|
||||
|
||||
export async function warmCommandCenterRows(page: Page) {
|
||||
// FlatList fills its overscan window in timed batches after the first visible frame.
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
});
|
||||
// FlatList fills its overscan window in timed batches after the first visible frame,
|
||||
// and a slow runner can still be mounting rows long after it. Start counting only
|
||||
// once a quiet period passes with no row render.
|
||||
await expect
|
||||
.poll(() => page.evaluate(countRowRendersOverQuietPeriod), {
|
||||
intervals: [0],
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
// Runs in the page: resets the render profile and counts row renders over two frames and 300ms.
|
||||
async function countRowRendersOverQuietPeriod() {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter((id) =>
|
||||
id.startsWith("CommandCenterResultRow:"),
|
||||
).length;
|
||||
}
|
||||
|
||||
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
|
||||
@@ -73,16 +89,17 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
|
||||
const rows = panel.locator(
|
||||
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
|
||||
);
|
||||
const count = await rows.count();
|
||||
const WORKSPACE_ROWS =
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"]):not([data-testid="command-center-workspace-agent"])';
|
||||
|
||||
/** The list mounts a window of rows, not every match. */
|
||||
export async function expectCommandCenterWorkspaceWindow(panel: Locator, total: number) {
|
||||
const count = await panel.locator(WORKSPACE_ROWS).count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
|
||||
@@ -22,14 +22,9 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
|
||||
const discoverPanel = new MutationObserver(() => {
|
||||
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
|
||||
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!panel || !firstResult) return;
|
||||
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport || viewport === panel) return;
|
||||
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
|
||||
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!viewport || !firstResult) return;
|
||||
|
||||
discoverPanel.disconnect();
|
||||
let frame = 0;
|
||||
@@ -119,15 +114,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
|
||||
|
||||
const scrollSamples = await panel
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.evaluate(async (firstResult) => {
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport) throw new Error("Could not find the command center scroll viewport");
|
||||
|
||||
.getByTestId("command-center-results")
|
||||
.evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
@@ -139,7 +127,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
expect(Math.max(...scrollSamples)).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
|
||||
/** All leads with three recent workspaces; a query searches every workspace. */
|
||||
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
|
||||
const workspaces = panel.locator(
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
|
||||
);
|
||||
@@ -150,9 +139,11 @@ export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locato
|
||||
),
|
||||
).size;
|
||||
|
||||
await expect.poll(distinctWorkspaceCount).toBe(5);
|
||||
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
|
||||
await expect.poll(distinctWorkspaceCount).toBe(3);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
|
||||
await expect(panel.getByText("Recent workspaces", { exact: true })).toHaveCount(0);
|
||||
await expect.poll(distinctWorkspaceCount).toBe(9);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export type CommandCenterTabName = "All" | "Workspaces" | "Files" | "Actions";
|
||||
|
||||
const TAB_PLACEHOLDERS: Record<CommandCenterTabName, string> = {
|
||||
All: "Search workspaces, files, and actions...",
|
||||
Workspaces: "Search workspaces...",
|
||||
Files: "Search files...",
|
||||
Actions: "Search actions...",
|
||||
};
|
||||
|
||||
/** Outside a workspace there are no files to search, so All does not offer them. */
|
||||
const TAB_PLACEHOLDERS_WITHOUT_WORKSPACE: Record<CommandCenterTabName, string> = {
|
||||
...TAB_PLACEHOLDERS,
|
||||
All: "Search workspaces and actions...",
|
||||
};
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
@@ -12,3 +27,46 @@ export async function closeCommandCenter(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
|
||||
}
|
||||
|
||||
function commandCenterTab(panel: Locator, name: CommandCenterTabName): Locator {
|
||||
return panel.getByTestId("command-center-tabs").getByRole("button", { name, exact: true });
|
||||
}
|
||||
|
||||
export async function selectCommandCenterTab(
|
||||
panel: Locator,
|
||||
name: CommandCenterTabName,
|
||||
options: { workspaceOpen?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await commandCenterTab(panel, name).click();
|
||||
await expectCommandCenterTab(panel, name, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab is selected and the search field asks for what that tab lists. Pass
|
||||
* `{ workspaceOpen: false }` when the command center was opened outside a workspace.
|
||||
*/
|
||||
export async function expectCommandCenterTab(
|
||||
panel: Locator,
|
||||
name: CommandCenterTabName,
|
||||
options: { workspaceOpen?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const placeholders =
|
||||
options.workspaceOpen === false ? TAB_PLACEHOLDERS_WITHOUT_WORKSPACE : TAB_PLACEHOLDERS;
|
||||
await expect(commandCenterTab(panel, name)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(panel.getByTestId("command-center-input")).toHaveAttribute(
|
||||
"placeholder",
|
||||
placeholders[name],
|
||||
);
|
||||
}
|
||||
|
||||
/** Exactly these tabs are offered, in this order. */
|
||||
export async function expectCommandCenterTabs(
|
||||
panel: Locator,
|
||||
names: readonly CommandCenterTabName[],
|
||||
): Promise<void> {
|
||||
await expect(panel.getByTestId("command-center-tabs").getByRole("button")).toHaveText([...names]);
|
||||
}
|
||||
|
||||
export function commandCenterSection(panel: Locator, title: string): Locator {
|
||||
return panel.getByTestId("command-center-results").getByText(title, { exact: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
|
||||
export interface ComposerControlTone {
|
||||
/** The visible glyph's stroke, or null when the control paints no glyph. */
|
||||
glyph: string | null;
|
||||
/** The visible label's color, or null when the control is icon-only. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export function composerControl(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
|
||||
* nodes, so this is the only way to tell whether they match.
|
||||
*/
|
||||
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
|
||||
return control.evaluate((element) => {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
|
||||
const label = Array.from(element.querySelectorAll("*"))
|
||||
.filter(visible)
|
||||
.find((node) =>
|
||||
[...node.childNodes].some(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
glyph: glyph ? getComputedStyle(glyph).stroke : null,
|
||||
label: label ? getComputedStyle(label).color : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} paints a label`).not.toBeNull();
|
||||
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
|
||||
}
|
||||
|
||||
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} is icon-only`).toBeNull();
|
||||
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
|
||||
}
|
||||
|
||||
/** The color the browser paints in a text input's placeholder. */
|
||||
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
|
||||
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
|
||||
}
|
||||
|
||||
/** The color the browser paints for a text node. */
|
||||
export async function readComposerTextTone(text: Locator): Promise<string | null> {
|
||||
return text.evaluate((element) => getComputedStyle(element).color);
|
||||
}
|
||||
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -254,6 +258,16 @@ export async function createCreationScenario(page: Page) {
|
||||
.filter(({ agent }) => agent.workspaceId === workspaceId)
|
||||
.map(({ agent }) => agent.id);
|
||||
},
|
||||
async expectAgentMetadata(metadata: Record<string, unknown>) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await project.client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.workspaceId === workspaceId,
|
||||
)?.agent.metadata,
|
||||
)
|
||||
.toEqual(metadata);
|
||||
},
|
||||
async expectAgentTitle(title: string) {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -276,6 +290,7 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -33,8 +33,10 @@ async function createRunningMockAgent(
|
||||
return { id: agent.id, title };
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
// Agents are found through their workspace: the query names the agent, the row shows it.
|
||||
async function searchCommandCenter(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
await page.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
@@ -78,8 +80,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await this.page.keyboard.press("Escape");
|
||||
@@ -111,8 +115,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await agentLink.click();
|
||||
|
||||
@@ -46,3 +46,12 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
export async function downloadFileFromPreview(page: Page): Promise<Download> {
|
||||
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
|
||||
const button = page.getByRole("button", { name: "Download", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await button.click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -232,8 +232,11 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(
|
||||
page: Page,
|
||||
shortcut = "Control+P",
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(shortcut);
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import appPackage from "../../../package.json";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import {
|
||||
expectToast,
|
||||
openPluginPage,
|
||||
pluginPageName,
|
||||
selectInstalledTab,
|
||||
selectPluginPageAction,
|
||||
} from "./plugin-gallery";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { startHostDaemon } from "./versioned-host-daemon";
|
||||
|
||||
/**
|
||||
* Settings → Plugins → Browse against a host whose registry is a local server. The one listing
|
||||
* installs from a local Git repository, so installing needs no network.
|
||||
*/
|
||||
|
||||
export const LISTING = {
|
||||
id: "acme/directory-example",
|
||||
name: "Directory example",
|
||||
description: "Adds a review checklist to every workspace",
|
||||
overview: "Directory example keeps a checklist beside your agents.",
|
||||
};
|
||||
|
||||
export const test = base.extend<{ directoryHost: { serverId: string; port: number } }>({
|
||||
directoryHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-plugin-directory-"));
|
||||
const registry = await startRegistry(await createListedPlugin(root));
|
||||
try {
|
||||
const host = await startHostDaemon({
|
||||
version: appPackage.version,
|
||||
pluginRegistryUrl: registry.url,
|
||||
});
|
||||
try {
|
||||
await provide({ serverId: host.serverId, port: host.port });
|
||||
} finally {
|
||||
await host.dispose();
|
||||
}
|
||||
} finally {
|
||||
await registry.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function createListedPlugin(root: string) {
|
||||
const repository = path.join(root, "repository");
|
||||
execFileSync("git", ["init", "-b", "main", repository], { stdio: "ignore" });
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "directory-example",
|
||||
name: LISTING.name,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repository, "index.client.ts"),
|
||||
"export default function () { return () => {}; }",
|
||||
);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "user.name=Paseo Tests", "-c", "user.email=e2e@paseo.test", ...args],
|
||||
{
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
},
|
||||
).trim();
|
||||
git("add", "-A");
|
||||
git("commit", "-m", "initial");
|
||||
const date = "2026-10-03T00:00:00.000Z";
|
||||
return {
|
||||
id: LISTING.id,
|
||||
name: LISTING.name,
|
||||
description: LISTING.description,
|
||||
categories: ["tools"],
|
||||
author: { github: "acme", name: "Acme" },
|
||||
repository: { url: "https://github.com/acme/directory-example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: git("rev-parse", "HEAD"),
|
||||
},
|
||||
media: [],
|
||||
submittedAt: date,
|
||||
reviewedAt: date,
|
||||
updatedAt: date,
|
||||
publishedAt: date,
|
||||
installs: 1200,
|
||||
};
|
||||
}
|
||||
|
||||
async function startRegistry(listing: Record<string, unknown>) {
|
||||
const documents: Record<string, unknown> = {
|
||||
"/index.json": {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Local", url: "http://127.0.0.1" },
|
||||
categories: [{ slug: "tools", label: "Tools" }],
|
||||
plugins: [listing],
|
||||
featured: [],
|
||||
generatedAt: "2026-10-03T00:00:00.000Z",
|
||||
},
|
||||
[`/plugins/${LISTING.id}.json`]: {
|
||||
...listing,
|
||||
readme: `# ${LISTING.name}\n\n${LISTING.overview}`,
|
||||
},
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
const document = request.url ? documents[request.url] : undefined;
|
||||
if (!document) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify(document));
|
||||
});
|
||||
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("Missing registry address");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
function listingRow(page: Page) {
|
||||
return page.getByTestId(`plugin-listing-${LISTING.id}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
function browseTab(page: Page) {
|
||||
return page.getByRole("button", { name: "Browse", exact: true });
|
||||
}
|
||||
|
||||
/** A host with no plugins opens Plugins on Browse. */
|
||||
export async function openDirectory(page: Page, host: { serverId: string; port: number }) {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, { ...host, label: "Plugin directory" });
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "plugins");
|
||||
await expect(browseTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(listingRow(page)).toContainText(LISTING.description);
|
||||
}
|
||||
|
||||
/** Install asks first, naming the author and version, then the row offers Uninstall. */
|
||||
export async function installListingFromRow(page: Page) {
|
||||
await listingRow(page).getByTestId(`plugin-install-${LISTING.id}`).click();
|
||||
const sheet = page.getByTestId("plugin-install-sheet");
|
||||
await expect(sheet).toContainText("Acme");
|
||||
await sheet.getByTestId("plugin-install-confirm").click();
|
||||
await expect(listingRow(page).getByTestId(`plugin-uninstall-${LISTING.id}`)).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Installing the first plugin keeps the user on Browse. */
|
||||
export async function expectStillBrowsing(page: Page) {
|
||||
await expect(browseTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("button", { name: /^Installed · 1/ })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openListingPage(page: Page) {
|
||||
await listingRow(page).getByText(LISTING.name, { exact: true }).click();
|
||||
await expect(pluginPageName(page)).toHaveText(LISTING.name);
|
||||
await expect(page.getByText(LISTING.overview, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalling keeps the plugin's page open on its listing, back on Install, whether the page was
|
||||
* opened from Browse or from the Installed tab.
|
||||
*/
|
||||
export async function uninstallFromPage(page: Page) {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginPageAction(page, LISTING.name, "Uninstall");
|
||||
await expectToast(page, `Uninstalled ${LISTING.name}`);
|
||||
await expect(pluginPageName(page)).toHaveText(LISTING.name);
|
||||
await expect(
|
||||
page.getByTestId(`plugin-install-${LISTING.id}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function installFromPage(page: Page) {
|
||||
await page.getByTestId(`plugin-install-${LISTING.id}`).filter({ visible: true }).click();
|
||||
await page.getByTestId("plugin-install-confirm").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${LISTING.name}`, exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
export async function openFromInstalledTab(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await selectInstalledTab(page);
|
||||
await openPluginPage(page, LISTING.name, "directory-example");
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
|
||||
/**
|
||||
* Settings → Host → Plugins: the Installed and Browse tabs, the page menu, the install sheet and a
|
||||
* plugin's own page. Helpers that take a plugin name expect the manifest name, which is the plugin
|
||||
* ID when the manifest has none.
|
||||
*/
|
||||
|
||||
export function pluginsRoute(serverId = getServerId()): string {
|
||||
return buildSettingsHostSectionRoute(serverId, "plugins");
|
||||
}
|
||||
|
||||
export function pluginPageRoute(pluginId: string, serverId = getServerId()): string {
|
||||
return `${pluginsRoute(serverId)}/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
/** Settings keeps the Plugins page mounted beneath a plugin's page, so read visible rows only. */
|
||||
export function pluginRow(page: Page, pluginId: string) {
|
||||
return page.getByTestId(`plugin-row-${pluginId}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
function pluginSwitch(page: Page, name: string, action: "Disable" | "Enable") {
|
||||
return page.getByRole("switch", { name: `${name}: ${action}`, exact: true }).filter({
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectToast(page: Page, message: string): Promise<void> {
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(message);
|
||||
}
|
||||
|
||||
// Tabs and search
|
||||
|
||||
export async function selectInstalledTab(page: Page): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: /^Installed/ });
|
||||
await tab.click();
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function searchInstalled(page: Page, query: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search installed", exact: true }).fill(query);
|
||||
}
|
||||
|
||||
export async function clearSearch(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Clear search", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search installed", exact: true })).toHaveValue(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Installed rows
|
||||
|
||||
export async function expectPluginOn(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeVisible();
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function expectPluginOff(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Enable")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function turnPluginOff(page: Page, name: string): Promise<void> {
|
||||
await pluginSwitch(page, name, "Disable").click();
|
||||
await expectPluginOff(page, name);
|
||||
}
|
||||
|
||||
export async function turnPluginOn(page: Page, name: string): Promise<void> {
|
||||
await pluginSwitch(page, name, "Enable").click();
|
||||
await expectPluginOn(page, name);
|
||||
}
|
||||
|
||||
/** The switch stays disabled while an action on the plugin runs, so a second one cannot start. */
|
||||
export async function expectPluginActionPending(page: Page, name: string): Promise<void> {
|
||||
await expect(pluginSwitch(page, name, "Disable")).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectPluginFailed(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRow(page, pluginId).getByText("failed", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPluginHealthy(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRow(page, pluginId)).toBeVisible();
|
||||
await expect(pluginRow(page, pluginId).getByText("failed", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectPluginRemoved(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`plugin-row-${pluginId}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Plugins on the host
|
||||
|
||||
function pluginsOffAlert(page: Page) {
|
||||
return page.getByRole("alert").filter({ hasText: "Plugins are off on" });
|
||||
}
|
||||
|
||||
export async function expectPluginsOff(page: Page): Promise<void> {
|
||||
await expect(pluginsOffAlert(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPluginsOn(page: Page): Promise<void> {
|
||||
await expect(pluginsOffAlert(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function turnOnPluginsFromAlert(page: Page): Promise<void> {
|
||||
await pluginsOffAlert(page).getByRole("button", { name: "Turn on", exact: true }).click();
|
||||
await expectPluginsOn(page);
|
||||
}
|
||||
|
||||
// The page menu
|
||||
|
||||
function galleryMenu(page: Page) {
|
||||
return page.getByRole("button", { name: "More plugin actions", exact: true }).filter({
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function openGalleryMenu(page: Page): Promise<void> {
|
||||
await galleryMenu(page).click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGalleryMenuWithoutSourceInstall(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await expect(page.getByRole("menuitem", { name: "Turn off plugins", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Install from source", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function turnOffPluginsFromMenu(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Turn off plugins", exact: true }).click();
|
||||
await expectToast(page, "Plugins disabled");
|
||||
await expectPluginsOff(page);
|
||||
}
|
||||
|
||||
export async function turnOnPluginsFromMenu(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Turn on plugins", exact: true }).click();
|
||||
await expectToast(page, "Plugins enabled");
|
||||
await expectPluginsOn(page);
|
||||
}
|
||||
|
||||
// Install from source
|
||||
|
||||
function sourceField(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Plugin source", exact: true });
|
||||
}
|
||||
|
||||
export async function openSourceInstall(page: Page): Promise<void> {
|
||||
await openGalleryMenu(page);
|
||||
await page.getByRole("menuitem", { name: "Install from source", exact: true }).click();
|
||||
await expect(sourceField(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSourceInstallTurnsPluginsOn(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("Plugins are not sandboxed. Installing also turns plugins on.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function submitSourceInstall(page: Page, source: string): Promise<void> {
|
||||
await sourceField(page).fill(source);
|
||||
await page.getByRole("button", { name: "Install", exact: true }).click();
|
||||
}
|
||||
|
||||
/** A failed install keeps the sheet open with the source, so the user can correct it. */
|
||||
export async function expectSourceInstallFailed(
|
||||
page: Page,
|
||||
source: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("alert").filter({ hasText: message })).toBeVisible();
|
||||
await expect(sourceField(page)).toHaveValue(source);
|
||||
}
|
||||
|
||||
export async function expectSourceInstalled(page: Page, name: string): Promise<void> {
|
||||
await expectToast(page, `Installed ${name}`);
|
||||
await expect(sourceField(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function installFromSource(page: Page, source: string, name: string): Promise<void> {
|
||||
await openSourceInstall(page);
|
||||
await submitSourceInstall(page, source);
|
||||
await expectSourceInstalled(page, name);
|
||||
}
|
||||
|
||||
// Updates
|
||||
|
||||
function updateRow(page: Page, pluginId: string) {
|
||||
return page.getByTestId(`plugin-update-${pluginId}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function updatePlugin(page: Page, pluginId: string): Promise<void> {
|
||||
await updateRow(page, pluginId).getByRole("button", { name: "Update", exact: true }).click();
|
||||
await expectToast(page, `${pluginId} updated`);
|
||||
await expect(page.getByTestId(`plugin-update-${pluginId}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectUpdateAvailable(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(updateRow(page, pluginId)).toBeVisible();
|
||||
}
|
||||
|
||||
// A plugin's page
|
||||
|
||||
export function pluginPageName(page: Page) {
|
||||
return page.getByTestId("plugin-page-name").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Opens the plugin's page from its row on the Installed tab. */
|
||||
export async function openPluginPage(page: Page, name: string, pluginId = name): Promise<void> {
|
||||
await pluginRow(page, pluginId).getByText(name, { exact: true }).click();
|
||||
await expect(pluginPageName(page)).toHaveText(name);
|
||||
}
|
||||
|
||||
export async function backToPlugins(page: Page, pluginId: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expect(pluginRow(page, pluginId)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openPluginPageActions(page: Page, name: string): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: `Actions for ${name}`, exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectPluginPageAction(
|
||||
page: Page,
|
||||
name: string,
|
||||
action: "Reload" | "Logs" | "Uninstall",
|
||||
): Promise<void> {
|
||||
await openPluginPageActions(page, name);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadPluginFromPage(page: Page, name: string): Promise<void> {
|
||||
await selectPluginPageAction(page, name, "Reload");
|
||||
await expectToast(page, `Reloaded ${name}`);
|
||||
}
|
||||
|
||||
export async function openPluginLogs(page: Page, name: string): Promise<void> {
|
||||
await selectPluginPageAction(page, name, "Logs");
|
||||
await expect(page.getByRole("dialog")).toContainText(`Logs: ${name}`);
|
||||
}
|
||||
|
||||
export async function closePluginLogs(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Uninstall confirms first; uninstalling a plugin that is not in the directory returns to Plugins. */
|
||||
export async function removePluginFromPage(page: Page, name: string): Promise<void> {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginPageAction(page, name, "Uninstall");
|
||||
await expectToast(page, `Uninstalled ${name}`);
|
||||
await expect(pluginPageName(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectPluginsRoute(page: Page): Promise<void> {
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
}
|
||||
@@ -9,6 +9,15 @@ import { addConnectedHostAndReload } from "./hosts";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import {
|
||||
backToPlugins,
|
||||
expectPluginFailed,
|
||||
expectPluginHealthy,
|
||||
openPluginPage,
|
||||
selectPluginPageAction,
|
||||
} from "./plugin-gallery";
|
||||
|
||||
const PLUGIN_ID = "requirements-example";
|
||||
|
||||
export const test = base.extend<{
|
||||
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
|
||||
@@ -80,7 +89,7 @@ export const test = base.extend<{
|
||||
async function writeRequirements(directory: string, paseo: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: { paseo } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +102,7 @@ export async function openRequirementHost(page: Page, host: { serverId: string;
|
||||
}
|
||||
|
||||
export async function expectAppMismatch(page: Page) {
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expectPluginFailed(page, PLUGIN_ID);
|
||||
await expect(
|
||||
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
|
||||
).toBeVisible();
|
||||
@@ -102,18 +111,21 @@ export async function expectAppMismatch(page: Page) {
|
||||
export async function correctRequirementAndReload(page: Page, directory: string) {
|
||||
await writeRequirements(directory, pluginRequirements.paseo);
|
||||
await reloadRequirementsPlugin(page);
|
||||
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
|
||||
await backToPlugins(page, PLUGIN_ID);
|
||||
await expectPluginHealthy(page, PLUGIN_ID);
|
||||
await expect(page.getByText(/Your app is/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function rejectDaemonMismatch(page: Page, directory: string) {
|
||||
await writeRequirements(directory, ">=100.0.0");
|
||||
await reloadRequirementsPlugin(page);
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await backToPlugins(page, PLUGIN_ID);
|
||||
await expectPluginFailed(page, PLUGIN_ID);
|
||||
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Reload lives on the plugin's page. */
|
||||
async function reloadRequirementsPlugin(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for requirements-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Reload", exact: true }).click();
|
||||
await openPluginPage(page, PLUGIN_ID);
|
||||
await selectPluginPageAction(page, PLUGIN_ID, "Reload");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,18 @@ import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import {
|
||||
backToPlugins,
|
||||
openPluginPage,
|
||||
openPluginPageActions,
|
||||
pluginPageName,
|
||||
pluginPageRoute,
|
||||
pluginRow,
|
||||
pluginsRoute,
|
||||
turnPluginOff,
|
||||
turnPluginOn,
|
||||
} from "./plugin-gallery";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
@@ -90,68 +100,64 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
export async function openDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openPluginScreen(page);
|
||||
await openSettingsExamplePage(page);
|
||||
}
|
||||
export async function openCompactDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, new URL(page.url()).pathname);
|
||||
await openPluginScreen(page);
|
||||
await openSettingsExamplePage(page);
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
async function openSettingsExamplePage(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await openSettingsFromCog(page);
|
||||
await openPluginPage(page, "settings-example");
|
||||
await expectSettingsExampleScreen(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
/** A plugin's page shows its settings inline. With one screen there are no tabs. */
|
||||
async function expectSettingsExampleScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
await expectAppRoute(page, pluginPageRoute("settings-example"));
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
/** The page's menu holds plugin actions only; its settings screens are on the page itself. */
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await openPluginPageActions(page, TABBED_PLUGIN_NAME);
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "General", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Advanced", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
/** Turning a plugin off unloads its settings screens, so its page no longer shows them. */
|
||||
export async function expectSettingsFollowPluginSwitch(page: Page) {
|
||||
await openPluginPage(page, "settings-example");
|
||||
await turnPluginOff(page, "settings-example");
|
||||
await expectSettingsHidden(page);
|
||||
await turnPluginOn(page, "settings-example");
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
export async function expectSettingsHidden(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toHaveCount(0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
/** A manifest `name` titles the row and its switch, and opens the plugin's page. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
const row = pluginRow(page, TABBED_PLUGIN_ID);
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(row).toHaveAccessibleName(TABBED_PLUGIN_NAME);
|
||||
}
|
||||
/** The page names the plugin by its manifest name, with the ID beneath it. */
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await openPluginPage(page, TABBED_PLUGIN_NAME, TABBED_PLUGIN_ID);
|
||||
await expect(
|
||||
page.getByText(TABBED_PLUGIN_ID, { exact: true }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expectAppRoute(page, pluginPageRoute(TABBED_PLUGIN_ID));
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
@@ -159,27 +165,25 @@ export async function openTabbedSettings(page: Page) {
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
/** Tabs swap the screen in place on the plugin's page, so Back still returns to Plugins. */
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${pluginPageRoute(TABBED_PLUGIN_ID)}?screenId=${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expect(pluginPageName(page)).toHaveText(TABBED_PLUGIN_NAME);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await page.goto(`${pluginPageRoute(TABBED_PLUGIN_ID)}/${title.toLowerCase()}`);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
@@ -235,18 +239,13 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
await openSettingsFromCog(page);
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
await openPluginPage(page, "settings-example");
|
||||
await expectSettingsExampleScreen(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
await backToPlugins(page, "settings-example");
|
||||
await expectAppRoute(page, pluginsRoute());
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface SeedDaemonClient {
|
||||
currentModeId: string | null;
|
||||
status: string;
|
||||
title?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
|
||||
).toHaveCSS("font-size", `${size}px`);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
|
||||
page: Page,
|
||||
size: number,
|
||||
): Promise<number> {
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
const anchor = navigation.getByRole("button", { name: "About", exact: true });
|
||||
const before = await anchor.evaluate((node) => ({
|
||||
top: node.getBoundingClientRect().top,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}));
|
||||
await changeSettingsInterfaceFontSize(page, size);
|
||||
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
|
||||
await expect
|
||||
.poll(() =>
|
||||
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
|
||||
)
|
||||
.toBeLessThan(before.height);
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
@@ -613,3 +640,192 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function revealSettingsSyntaxTheme(page: Page) {
|
||||
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-detail-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
const keywordColor = await page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).color);
|
||||
return { offset, keywordColor };
|
||||
}
|
||||
|
||||
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await page.getByRole("menuitem", { name: theme, exact: true }).click();
|
||||
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSyntaxColorChanged(
|
||||
page: Page,
|
||||
previousColor: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first(),
|
||||
).not.toHaveCSS("color", previousColor);
|
||||
}
|
||||
|
||||
export async function changeSettingsTypography(page: Page): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
|
||||
await input.fill("18");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("18");
|
||||
await expect(input).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
|
||||
await familyInput.fill("Courier New, Courier, monospace");
|
||||
await familyInput.press("Enter");
|
||||
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
|
||||
await codeInput.fill("16");
|
||||
await codeInput.press("Enter");
|
||||
await expect(codeInput).toHaveValue("16");
|
||||
const preview = page.getByRole("img", {
|
||||
name: "Live preview of content typography, syntax theme, and code font",
|
||||
});
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"line-height",
|
||||
"24px",
|
||||
);
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"font-family",
|
||||
/Courier/,
|
||||
);
|
||||
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
|
||||
await contentInput.fill("20");
|
||||
await contentInput.press("Enter");
|
||||
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
|
||||
"font-size",
|
||||
"20px",
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
|
||||
for (const [name, minimum, maximum] of [
|
||||
["Interface font size", "10", "21"],
|
||||
["Content font size", "10", "21"],
|
||||
["Code font size", "9", "22"],
|
||||
] as const) {
|
||||
const input = page.getByRole("textbox", { name, exact: true });
|
||||
await input.fill("999");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
// Clamp again when the saved value is already at the maximum.
|
||||
await input.fill("999");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("1");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
await input.fill("1");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample actual paints, including the first workspace frame after Settings. */
|
||||
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
|
||||
await page.evaluate(() => {
|
||||
const capture = {
|
||||
frames: [] as {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[],
|
||||
stopped: false,
|
||||
};
|
||||
Object.assign(window, { __sidebarFrameCapture: capture });
|
||||
const start = performance.now();
|
||||
function sample() {
|
||||
if (capture.stopped) return;
|
||||
const close = document.querySelector('[data-testid="sidebar-close"]');
|
||||
capture.frames.push({
|
||||
time: performance.now() - start,
|
||||
pathname: location.pathname,
|
||||
settingsVisible:
|
||||
document
|
||||
.querySelector('[data-testid="settings-sidebar"]')
|
||||
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
|
||||
sidebarVisible:
|
||||
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
|
||||
close!.getBoundingClientRect().x >= 0 &&
|
||||
close!.getBoundingClientRect().right <= innerWidth,
|
||||
closeX: close?.getBoundingClientRect().x ?? null,
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await returnAction();
|
||||
return page.evaluate(async () => {
|
||||
const capture = (
|
||||
window as typeof window & {
|
||||
__sidebarFrameCapture: {
|
||||
frames: {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[];
|
||||
stopped: boolean;
|
||||
};
|
||||
}
|
||||
).__sidebarFrameCapture;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
|
||||
capture.stopped = true;
|
||||
return capture.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureCompactSidebarContext(page: Page) {
|
||||
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
|
||||
if (!sidebar) throw new Error("Open sidebar has no workspace list");
|
||||
await expect
|
||||
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
const offset = await sidebar.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return async () => {
|
||||
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
|
||||
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
|
||||
await sidebar.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function closeCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function openSettingsFromCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectCompactSettingsList(page);
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ async function expectContextMenuAtPointer(
|
||||
menuTestID: string,
|
||||
anchor: ContextMenuAnchor,
|
||||
): Promise<void> {
|
||||
const bounds = await page.getByTestId(menuTestID).boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
|
||||
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
|
||||
// The visible measuring surface is replaced when layout places the menu at its anchor.
|
||||
await expect(async () => {
|
||||
const bounds = await page.getByTestId(menuTestID).boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
|
||||
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -147,6 +147,84 @@ export function usageModal(page: Page): Locator {
|
||||
return visible(page, "usage-modal-body");
|
||||
}
|
||||
|
||||
/** The Usage modal's cards, top to bottom, by report id. */
|
||||
export async function expectUsageCardOrder(page: Page, reportIds: string[]): Promise<void> {
|
||||
const cards = usageModal(page).locator('[data-testid^="usage-report-"]');
|
||||
await expect(cards).toHaveCount(reportIds.length);
|
||||
await expect
|
||||
.poll(() =>
|
||||
cards.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute("data-testid")?.slice("usage-report-".length)),
|
||||
),
|
||||
)
|
||||
.toEqual(reportIds);
|
||||
}
|
||||
|
||||
/** Turns a card on or off from its header switch, named by its source and account. */
|
||||
export async function setUsageCardOn(page: Page, name: string, on: boolean): Promise<void> {
|
||||
const cardSwitch = usageModal(page).getByRole("switch", { name: `Show ${name}`, exact: true });
|
||||
await cardSwitch.click();
|
||||
await expect(cardSwitch).toBeChecked({ checked: on });
|
||||
}
|
||||
|
||||
/** Drags a card by its handle until it sits above another card, with the mouse. */
|
||||
export async function dragUsageCardAbove(
|
||||
page: Page,
|
||||
reportId: string,
|
||||
targetReportId: string,
|
||||
): Promise<void> {
|
||||
const card = usageModal(page).getByTestId(`usage-report-${reportId}`);
|
||||
const target = usageModal(page).getByTestId(`usage-report-${targetReportId}`);
|
||||
await card.hover();
|
||||
const handle = await card.getByTestId("usage-card-drag-handle").boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
if (!handle || !targetBox) throw new Error("Both cards must be on screen to drag.");
|
||||
const x = handle.x + handle.width / 2;
|
||||
await page.mouse.move(x, handle.y + handle.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x, handle.y + handle.height / 2 - 10, { steps: 3 });
|
||||
await page.mouse.move(x, targetBox.y + 4, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording where the cards are painted on every frame after the next mouse release.
|
||||
* Call the returned function to stop and read the frames, each as "id@top" for every card.
|
||||
*/
|
||||
export async function recordUsageCardPositionsAfterRelease(
|
||||
page: Page,
|
||||
): Promise<() => Promise<string[]>> {
|
||||
const recording = await usageModal(page).evaluateHandle((modal) => {
|
||||
const state = { released: false, stopped: false, frames: [] as string[] };
|
||||
const markReleased = () => {
|
||||
state.released = true;
|
||||
};
|
||||
window.addEventListener("mouseup", markReleased, { capture: true, once: true });
|
||||
const recordFrame = () => {
|
||||
if (state.stopped) return;
|
||||
if (state.released) {
|
||||
const cards = Array.from(modal.querySelectorAll('[data-testid^="usage-report-"]'));
|
||||
state.frames.push(
|
||||
cards
|
||||
.map((card) => {
|
||||
const id = card.getAttribute("data-testid")?.slice("usage-report-".length);
|
||||
return `${id}@${Math.round(card.getBoundingClientRect().top)}`;
|
||||
})
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(recordFrame);
|
||||
};
|
||||
requestAnimationFrame(recordFrame);
|
||||
return state;
|
||||
});
|
||||
return () =>
|
||||
recording.evaluate((state) => {
|
||||
state.stopped = true;
|
||||
return state.frames;
|
||||
});
|
||||
}
|
||||
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
|
||||
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
@@ -167,14 +245,18 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
|
||||
|
||||
/**
|
||||
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
|
||||
* but buttons, so no row presses or highlights on hover.
|
||||
* but the header's buttons and on/off switch, so no row presses or highlights on hover.
|
||||
*/
|
||||
export async function expectUnpinnableRows(card: Locator): Promise<void> {
|
||||
await expect(card.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
|
||||
const focusable = await card
|
||||
.locator("[tabindex]")
|
||||
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
|
||||
.evaluateAll(
|
||||
(nodes) =>
|
||||
nodes.filter((node) => !["button", "switch"].includes(node.getAttribute("role") ?? ""))
|
||||
.length,
|
||||
);
|
||||
expect(focusable).toBe(0);
|
||||
}
|
||||
|
||||
@@ -200,6 +282,18 @@ export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
|
||||
const summary = page.getByRole("button", { name: /^Usage:/ });
|
||||
await summary.hover();
|
||||
const hoveredFill = await summary.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
|
||||
await page.mouse.up();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
@@ -261,6 +355,19 @@ export async function openUsageOptions(page: Page): Promise<void> {
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
/** Picks the host Usage shows, from the Host row of its Settings. */
|
||||
export async function pickUsageHost(page: Page, serverId: string): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, "usage-options-host").click();
|
||||
await visible(page, `usage-options-host-${serverId}`).click();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The host Usage shows, under its title; absent with a single host. */
|
||||
export function usageModalHost(page: Page): Locator {
|
||||
return visible(page, "usage-modal-host");
|
||||
}
|
||||
|
||||
export function usageOptionsSurface(page: Page): Locator {
|
||||
return page
|
||||
.locator(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user