mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec0ac3c25 | ||
|
|
6cb9685a6d | ||
|
|
8a972b8c2e | ||
|
|
cc56b392de | ||
|
|
cb331532ad | ||
|
|
da994fb400 | ||
|
|
c4baf750f2 | ||
|
|
31a408d108 | ||
|
|
3be00f5105 | ||
|
|
6e704f803c | ||
|
|
949462f39b | ||
|
|
53d91d63da | ||
|
|
4709b49dc4 | ||
|
|
f938bf963d | ||
|
|
1131241b73 | ||
|
|
ce39ab6f09 | ||
|
|
c242f4d8db | ||
|
|
66aeff70d1 | ||
|
|
adf8badade | ||
|
|
1b45d92dac | ||
|
|
42ed0a8a99 | ||
|
|
d8fca6fdc6 | ||
|
|
77f97f82bc | ||
|
|
26b1e122ae | ||
|
|
7dfb63d0c4 | ||
|
|
5c81d08959 | ||
|
|
96b2bea130 | ||
|
|
33338be16e | ||
|
|
32230e24c5 | ||
|
|
d641cb5179 | ||
|
|
58094053dc | ||
|
|
3f75a11a49 | ||
|
|
f0807c5deb | ||
|
|
e0ff0959a1 | ||
|
|
591dd3bc92 | ||
|
|
8fb31cf8c0 | ||
|
|
b99d2a010d | ||
|
|
41011ddc60 | ||
|
|
e9d440259c | ||
|
|
0d697e6330 | ||
|
|
5413b8122c | ||
|
|
8960e76d19 | ||
|
|
218bcaaa6b | ||
|
|
30cc0a8c21 | ||
|
|
a362664f47 | ||
|
|
20ce734367 | ||
|
|
ca2bdd66eb | ||
|
|
1aaac5b231 | ||
|
|
b60b74384b | ||
|
|
034f639c88 | ||
|
|
55e8cff9f4 | ||
|
|
06be203c5c | ||
|
|
007a8e36ae | ||
|
|
eb6bed8447 | ||
|
|
bfbd4d5175 | ||
|
|
52b84b7835 | ||
|
|
d68271565a | ||
|
|
ce7b14a143 | ||
|
|
c7e137d4b4 | ||
|
|
d37262be56 | ||
|
|
0952ddbd4b | ||
|
|
67628b48fe | ||
|
|
eba5bdf810 | ||
|
|
66676d49e6 | ||
|
|
064243288b | ||
|
|
a880c9619d | ||
|
|
6740d41fab | ||
|
|
ca6b54fc2e | ||
|
|
4cd23a8f17 | ||
|
|
3084f32852 | ||
|
|
ce8196f6c5 | ||
|
|
40b95f70f4 | ||
|
|
93b9ac9ded | ||
|
|
eb8b5b8f94 | ||
|
|
51b9ad7695 | ||
|
|
1c4400194a | ||
|
|
1502b7f90f | ||
|
|
16f688b726 | ||
|
|
b507fc1986 | ||
|
|
e546497a21 | ||
|
|
b807c261ca | ||
|
|
85be2121b8 | ||
|
|
f4eeb8a538 | ||
|
|
e1b20384a1 | ||
|
|
fc710be1af | ||
|
|
66e1523f2f | ||
|
|
336e7d1b89 | ||
|
|
6f6712be62 | ||
|
|
a296bf3502 | ||
|
|
3857ed3365 | ||
|
|
736f7bf042 | ||
|
|
07a2e4accd | ||
|
|
1f64e68987 | ||
|
|
e06adf7a3e | ||
|
|
d2cc18b3a1 | ||
|
|
5062d5ee90 | ||
|
|
71dc9f44c9 | ||
|
|
7495c20f78 | ||
|
|
b92a34ab3d | ||
|
|
8018231a00 | ||
|
|
555818f5f7 | ||
|
|
27caf913f6 | ||
|
|
835bc49487 | ||
|
|
63302287fe | ||
|
|
3e3a707f5a | ||
|
|
d4ac64f335 | ||
|
|
70cb34927a | ||
|
|
b280ca5355 | ||
|
|
da8d58bfee | ||
|
|
7c2812dde6 | ||
|
|
b474beedce | ||
|
|
e2f0779379 | ||
|
|
a166a3317a | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a | ||
|
|
14519bdb5b | ||
|
|
891e62309c | ||
|
|
6ae6308cfb | ||
|
|
ef77b823ae | ||
|
|
60f29a4350 | ||
|
|
8efb21f55c | ||
|
|
b2666a0aec | ||
|
|
82d5f6c580 | ||
|
|
da75aa7a4a | ||
|
|
86c60d367c | ||
|
|
5afa1a9f8e | ||
|
|
2d7adfc450 | ||
|
|
7d9741ef77 | ||
|
|
caf9300358 | ||
|
|
f0dfb9134c | ||
|
|
56c051503b | ||
|
|
85354fcb6a | ||
|
|
2dd931c8bd | ||
|
|
58eafd81e5 | ||
|
|
eab9ac3b03 | ||
|
|
e02fa9a5f8 | ||
|
|
b117e3fb0f | ||
|
|
2ac288f4d0 | ||
|
|
1008e9fffc | ||
|
|
74613e4bc3 | ||
|
|
0e5bcbd9f5 | ||
|
|
a7d9bb34fc | ||
|
|
fd65985e9d | ||
|
|
254166e7c1 |
@@ -406,10 +406,7 @@
|
|||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"packages/app/src/app/_layout.tsx",
|
"packages/app/src/app/_layout.tsx",
|
||||||
"packages/app/src/app/pair-scan.tsx",
|
|
||||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||||
"packages/app/src/components/add-host-method-modal.tsx",
|
|
||||||
"packages/app/src/components/add-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
"packages/app/src/components/agent-list.tsx",
|
||||||
"packages/app/src/components/agent-status-dot.tsx",
|
"packages/app/src/components/agent-status-dot.tsx",
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
"packages/app/src/components/attachment-lightbox.tsx",
|
||||||
@@ -428,7 +425,6 @@
|
|||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
"packages/app/src/components/hosts/host-picker.tsx",
|
||||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||||
"packages/app/src/components/left-sidebar.tsx",
|
"packages/app/src/components/left-sidebar.tsx",
|
||||||
"packages/app/src/components/pair-link-modal.tsx",
|
|
||||||
"packages/app/src/components/plan-card.tsx",
|
"packages/app/src/components/plan-card.tsx",
|
||||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||||
"packages/app/src/components/question-form-card.tsx",
|
"packages/app/src/components/question-form-card.tsx",
|
||||||
@@ -543,14 +539,10 @@
|
|||||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||||
"packages/app/src/agent-stream/view.tsx",
|
"packages/app/src/agent-stream/view.tsx",
|
||||||
"packages/app/src/app/pair-scan.tsx",
|
|
||||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||||
"packages/app/src/command-center/command-center.tsx",
|
"packages/app/src/command-center/command-center.tsx",
|
||||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||||
"packages/app/src/components/add-host-method-modal.tsx",
|
|
||||||
"packages/app/src/components/add-host-modal.tsx",
|
|
||||||
"packages/app/src/components/add-project-flow.tsx",
|
"packages/app/src/components/add-project-flow.tsx",
|
||||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
"packages/app/src/components/agent-list.tsx",
|
||||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
"packages/app/src/components/attachment-lightbox.tsx",
|
||||||
|
|||||||
+73
-6
@@ -1,15 +1,82 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
## 0.15.0 - 2026-11-03
|
||||||
|
|
||||||
Added:
|
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||||
|
|
||||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||||
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
|
||||||
|
|
||||||
Improved:
|
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||||
|
|
||||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
### Added
|
||||||
|
|
||||||
|
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||||
|
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
|
||||||
|
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||||
|
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||||
|
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||||
|
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
|
||||||
|
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||||
|
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||||
|
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||||
|
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||||
|
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||||
|
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||||
|
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||||
|
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||||
|
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||||
|
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
|
||||||
|
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||||
|
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
|
||||||
|
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
|
||||||
|
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
|
||||||
|
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||||
|
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||||
|
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||||
|
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||||
|
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
|
||||||
|
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||||
|
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
|
||||||
|
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
|
||||||
|
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||||
|
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
|
||||||
|
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
|
||||||
|
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||||
|
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||||
|
- Fixed chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||||
|
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
|
||||||
|
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||||
|
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||||
|
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||||
|
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||||
|
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
|
||||||
|
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
|
||||||
|
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
|
|||||||
+74
-7
@@ -1,15 +1,82 @@
|
|||||||
# 更新日志
|
# Changelog
|
||||||
|
|
||||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
## 0.15.0 - 2026-11-03
|
||||||
|
|
||||||
新增:
|
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||||
|
|
||||||
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||||
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
|
||||||
|
|
||||||
改进:
|
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
|
||||||
|
|
||||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
### 新增
|
||||||
|
|
||||||
|
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||||
|
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
|
||||||
|
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
|
||||||
|
- 新增可选设置,在主机重启后于原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||||
|
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||||
|
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||||
|
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
|
||||||
|
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||||
|
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||||
|
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||||
|
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||||
|
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||||
|
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||||
|
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||||
|
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||||
|
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||||
|
|
||||||
|
### 插件
|
||||||
|
|
||||||
|
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||||
|
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
|
||||||
|
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
|
||||||
|
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||||
|
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||||
|
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
|
||||||
|
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
|
||||||
|
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
|
||||||
|
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
|
||||||
|
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||||
|
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||||
|
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||||
|
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
|
||||||
|
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||||
|
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
|
||||||
|
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
|
||||||
|
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||||
|
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
|
||||||
|
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
|
||||||
|
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||||
|
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||||
|
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||||
|
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||||
|
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
|
||||||
|
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||||
|
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||||
|
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||||
|
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||||
|
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
|
||||||
|
- Agent 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
|
||||||
|
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||||
|
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
|
||||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||||
|
|||||||
+34
-14
@@ -14,9 +14,10 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl
|
|||||||
|
|
||||||
## Daemon restart recovery
|
## Daemon restart recovery
|
||||||
|
|
||||||
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
|
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
|
||||||
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
|
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
|
||||||
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
|
reopens the saved provider conversation and settings, then sends `Resume working.`. Customize
|
||||||
|
the prompt with `agents.resumePrompt` or the same settings page.
|
||||||
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
||||||
|
|
||||||
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||||
@@ -182,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
|
|||||||
|
|
||||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||||
|
|
||||||
## Background workspaces
|
## Background visibility
|
||||||
|
|
||||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
An agent is effectively background when it or its workspace is background:
|
||||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
`agent.background || workspace.background`. Background controls discovery only. Background agents
|
||||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
and the agents in background workspaces are ordinary durable agents: provider history, plugin
|
||||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
|
||||||
creates one without an override, and accept explicit true or false.
|
normal behavior. Both flags are fixed at creation.
|
||||||
|
|
||||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
|
||||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
|
||||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
|
||||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
|
||||||
|
works.
|
||||||
|
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
|
||||||
|
background** is on.
|
||||||
|
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
|
||||||
|
caller's own `background`. A new workspace takes the caller's effective background. A request
|
||||||
|
that creates its workspace applies `background` to both the agent and the workspace. Explicit
|
||||||
|
true or false always wins.
|
||||||
|
|
||||||
|
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
|
||||||
|
Default workspace and agent lists, including History requests, exclude background work;
|
||||||
|
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
|
||||||
|
replicates inclusively and filters at each surface.
|
||||||
|
|
||||||
|
Sources without the `background_agents` hello capability get workspace-only visibility on
|
||||||
|
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
|
||||||
|
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
|
||||||
|
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
|
||||||
|
|
||||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
|
||||||
|
They gate a background agent placed in an existing workspace or under a caller on
|
||||||
|
`features.backgroundAgents`.
|
||||||
|
|
||||||
### Private daemon helpers
|
### Private daemon helpers
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
|||||||
|
|
||||||
## Local build + install
|
## Local build + install
|
||||||
|
|
||||||
|
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
|
||||||
|
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
|
||||||
|
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
|
||||||
|
regression renders real gradient pixels across growing and shrinking layouts, including a nested
|
||||||
|
group. From `packages/app/android`, run it on your selected emulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
|
||||||
|
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
|
||||||
|
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||||
|
```
|
||||||
|
|
||||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||||
It backports
|
It backports
|
||||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||||
@@ -162,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
|||||||
|
|
||||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||||
|
|
||||||
## Inverted timeline selection
|
## Inverted timeline input
|
||||||
|
|
||||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||||
@@ -171,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
|||||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||||
|
|
||||||
|
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
|
||||||
|
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
|
||||||
|
bounds and React Native's scroll events and disabled-scroll behavior.
|
||||||
|
|
||||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
|
|||||||
|
|
||||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||||
operations; requesting this feature gives update guidance.
|
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||||
|
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||||
|
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||||
|
`replaceIdentity`, or A restarts.
|
||||||
|
|
||||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||||
|
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
||||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||||
@@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## OpenCode with an existing v2 server
|
||||||
|
|
||||||
|
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"providers": {
|
||||||
|
"opencode": {
|
||||||
|
"env": {
|
||||||
|
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
||||||
|
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
||||||
|
|
||||||
|
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
||||||
|
|
||||||
|
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
||||||
|
|
||||||
|
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Z.AI (Zhipu) coding plan
|
## Z.AI (Zhipu) coding plan
|
||||||
|
|
||||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||||
@@ -747,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
|
|||||||
|
|
||||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||||
|
|
||||||
|
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
|
||||||
|
|
||||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||||
|
|||||||
+12
-12
@@ -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`.
|
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/screens/settings/settings-section.tsx`). 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 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.
|
||||||
|
|
||||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
|
|||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
|
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
|
||||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -68,9 +68,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
|||||||
|
|
||||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
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 in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px 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()`.
|
||||||
|
|
||||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||||
|
|
||||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
|
|||||||
|
|
||||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||||
|
|
||||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Both sit on `surface0`, the same base as a screen, so settings cards and inputs inside a modal read the way they do on the Settings screen. Raw `Modal` is wrong for any of these.
|
||||||
|
|
||||||
`<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.
|
`<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.
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
|
|||||||
|
|
||||||
## 7. Density and rhythm
|
## 7. Density and rhythm
|
||||||
|
|
||||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
|
||||||
|
|
||||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||||
|
|
||||||
@@ -126,7 +126,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 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, `xs` 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.
|
||||||
|
|
||||||
The whitespace is the design.
|
The whitespace is the design.
|
||||||
|
|
||||||
@@ -197,11 +197,11 @@ Terminology:
|
|||||||
|
|
||||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||||
|
|
||||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||||
|
|
||||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||||
|
|
||||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||||
|
|
||||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
|||||||
|
|
||||||
## 14. Forbidden
|
## 14. Forbidden
|
||||||
|
|
||||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
- `fontWeight.medium` on row titles, body text, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
||||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||||
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
|||||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||||
|
|||||||
+2
-1
@@ -593,7 +593,8 @@ rerun passes only by landing elsewhere.
|
|||||||
|
|
||||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
|
||||||
|
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
|
||||||
|
|
||||||
## ACP provider catalog versions
|
## ACP provider catalog versions
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
|
|||||||
|
|
||||||
## App-Wide Route Hops
|
## App-Wide Route Hops
|
||||||
|
|
||||||
|
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
|
||||||
|
flow; callers choose what happens after success. Settings opts into the new host’s
|
||||||
|
Overview. Do not send callers to Settings with an `addHost` query parameter: a
|
||||||
|
retained route replays that request when appearance changes remount its content.
|
||||||
|
The flow provider and sheet host stay outside appearance keys so open drafts survive.
|
||||||
|
|
||||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||||
branch on its current route.
|
branch on its current route.
|
||||||
@@ -90,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
|
|||||||
History returns.
|
History returns.
|
||||||
|
|
||||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||||
Back returns to that host's Plugins page.
|
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
|
||||||
|
so switching tabs adds no stack entries.
|
||||||
|
|
||||||
Settings detail routes are separate siblings on purpose. Keep
|
Settings detail routes are separate siblings on purpose. Keep
|
||||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||||
|
|||||||
+16
-13
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
|
|||||||
- `matchesHost` from manifest `cloudHosts`
|
- `matchesHost` from manifest `cloudHosts`
|
||||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||||
|
|
||||||
Current change-request lookup uses two identities deliberately:
|
Current change-request lookup matches the branch and repository for both open and
|
||||||
|
terminal requests. Prefer an open request, then a terminal request at the local
|
||||||
|
HEAD, then the most recently updated terminal request. A reused branch can show
|
||||||
|
its previous request until a new one opens. Auto-archive separately requires an
|
||||||
|
observed open-to-merged transition and a fresh exact match between checkout HEAD
|
||||||
|
and the PR head, along with the existing workspace safety checks.
|
||||||
|
|
||||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
Keep checkout HEAD in adapter cache and poll identities so checkout changes
|
||||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
refresh the facts used by archive eligibility.
|
||||||
behind, or contain commits that have not been pushed yet.
|
|
||||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
|
||||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
|
||||||
selecting the newest terminal request by branch alone can silently attach an
|
|
||||||
old promotion or feature request to new work.
|
|
||||||
|
|
||||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
|
||||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
|
||||||
previous commit's cached terminal status until the cache expires.
|
|
||||||
|
|
||||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||||
@@ -125,7 +121,8 @@ rendering stack:
|
|||||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment
|
||||||
|
and Markdown shorthand references)
|
||||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||||
|
|
||||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||||
@@ -212,6 +209,12 @@ are stale, run `npm run build:server`.
|
|||||||
neutral extractor matches the pasted host and full repository path against
|
neutral extractor matches the pasted host and full repository path against
|
||||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
the current remote before it invokes cwd-routed forge search. Do not add a
|
||||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
||||||
|
- Shorthand references in rendered Markdown (`#12`, GitLab and Gitea `!12`,
|
||||||
|
cross-repository `owner/repo#12`) come from the `sigil` on each
|
||||||
|
`referencePaths` entry. Leave `sigil` off a kind the forge does not link in
|
||||||
|
prose: GitHub has no `!`, and its `#` targets `/issues/`, which GitHub
|
||||||
|
redirects to the pull request. `packages/app/src/git/forge-shorthand` builds
|
||||||
|
the recognition pattern from the workspace descriptor's forge and remote.
|
||||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||||
not protocol.
|
not protocol.
|
||||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
|
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
|
||||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Keyboard shortcuts
|
||||||
|
|
||||||
|
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
|
||||||
|
|
||||||
|
## Badges and routing
|
||||||
|
|
||||||
|
Routing runs whenever the app mounts its key sources, independently of layout.
|
||||||
|
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
|
||||||
|
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
|
||||||
|
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
|
||||||
|
keydown, including on touch devices. Compact Electron keeps its bindings.
|
||||||
|
|
||||||
|
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
|
||||||
|
panel is hidden. Retain that model while keyboard navigation is active; gating it on
|
||||||
|
panel visibility leaves number shortcuts with no targets after a compact startup.
|
||||||
|
|
||||||
|
Composer send-key customization remains a separate capability. Native retains its
|
||||||
|
existing Return submission path; advertising its web-only customization would expose
|
||||||
|
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
|
||||||
|
press is still observed by the window.
|
||||||
|
|
||||||
|
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||||
|
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
|
||||||
|
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
|
||||||
|
`UIKeyCommand`, the window observed it and the command handler also ran.
|
||||||
|
|
||||||
|
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
|
||||||
|
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
|
||||||
|
normally. Keep Simulator input capture off: Send Keyboard Input to Device
|
||||||
|
prevented synthetic AppleScript input from arriving, including plain text.
|
||||||
|
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
|
||||||
|
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
|
||||||
|
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
|
||||||
|
through rebinds. For numeric injection, use AppleScript physical key codes (for example
|
||||||
|
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
|
||||||
|
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
|
||||||
|
Verify the iOS absence case on a physical iPhone.
|
||||||
|
|
||||||
|
Never register Backspace: it would take deletion away from text fields. Changes
|
||||||
|
to the native module require a native rebuild; an over-the-air JavaScript update
|
||||||
|
cannot deliver them.
|
||||||
|
|
||||||
|
## Overlay ownership
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
open modal rise above its own children and creates competing focus traps.
|
||||||
|
|
||||||
|
Declare Enter and arrow keys only while the surface owns them. Native sources
|
||||||
|
use the top overlay's declarations to take those keys before text controls edit.
|
||||||
|
Backspace stays with the focused input. Composition confirmation must not select
|
||||||
|
an overlay row or submit the composer.
|
||||||
|
|
||||||
|
Android modals have their own windows. Observe them through React Native's
|
||||||
|
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
|
||||||
|
Expo's root-container handler, so an activity root-view wrapper misses presses.
|
||||||
|
A modal can forward an unhandled event into the activity window. Observe the event
|
||||||
|
once across that nested delivery without retaining recycled Android `KeyEvent`s.
|
||||||
|
Keep an overlay-owned press consumed through key-up, even if its key-down closes
|
||||||
|
the overlay; otherwise the modal's default Escape handler can close a second layer.
|
||||||
|
|
||||||
|
iOS scene startup installs the observing window after Expo creates its root view.
|
||||||
|
Keep that integration in the hardware keyboard module's config plugin. Its window
|
||||||
|
owns both the press stream and the text-control key commands, including above modals.
|
||||||
|
|
||||||
|
## Focus scopes
|
||||||
|
|
||||||
|
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
|
||||||
|
scope derives from those declarations and command-center state. Releasing an old
|
||||||
|
surface must not clear a newer owner's declaration during a tab switch. Web keeps
|
||||||
|
its DOM-derived scope.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -10,9 +10,9 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
|
|||||||
|
|
||||||
## Links and images
|
## Links and images
|
||||||
|
|
||||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
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).
|
||||||
|
|
||||||
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.
|
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
|
## Engine constraints
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -357,9 +357,27 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
|
|||||||
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
||||||
params.
|
params.
|
||||||
|
|
||||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
|
||||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
|
||||||
Paseo gives each plugin installation its own query client.
|
for request state and caching; Paseo gives each plugin installation its own query client.
|
||||||
|
|
||||||
|
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
|
||||||
|
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
|
||||||
|
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
|
||||||
|
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
|
||||||
|
never sees an event for an unknown subscription. The app installation's lifetime aborts every
|
||||||
|
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
|
||||||
|
mounted hooks.
|
||||||
|
|
||||||
|
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
|
||||||
|
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
|
||||||
|
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
|
||||||
|
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
|
||||||
|
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
|
||||||
|
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
|
||||||
|
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
|
||||||
|
path, so it is authorized like any other invoke. The author contract, including the native upload
|
||||||
|
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
|
||||||
|
|
||||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
||||||
|
|||||||
+50
-4
@@ -30,6 +30,50 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
|||||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||||
for native keys and examples.
|
for native keys and examples.
|
||||||
|
|
||||||
|
### Options flow
|
||||||
|
|
||||||
|
`options` on the agent snapshot is the only option state. Controls describe how to draw an
|
||||||
|
option and read `options`; they hold no value of their own. Any option can be read and set with
|
||||||
|
or without a control.
|
||||||
|
|
||||||
|
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
||||||
|
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
||||||
|
differs from what it was given, including a setting nobody asked for (a native default such as
|
||||||
|
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
||||||
|
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
||||||
|
native default the provider later changes does not follow. `options` on the snapshot is what
|
||||||
|
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
|
||||||
|
Editing a provider default in `config.json` affects agents the next time they open.
|
||||||
|
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
||||||
|
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
||||||
|
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||||
|
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
||||||
|
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
||||||
|
starts its query, and replacing a started query would end its background work, so after the
|
||||||
|
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
|
||||||
|
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
|
||||||
|
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
|
||||||
|
it ended up with when they differ from the request. The daemon
|
||||||
|
stores the patch and that result in one step after the call resolves, onto the agent's options
|
||||||
|
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
|
||||||
|
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
|
||||||
|
before it refused, it reports the moved values once as `options_changed` after the call has
|
||||||
|
ended, because the stored options must match what runs.
|
||||||
|
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
|
||||||
|
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
|
||||||
|
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
|
||||||
|
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
|
||||||
|
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
|
||||||
|
behind the provider id, following `derivedFromProviderId`, so a custom provider with
|
||||||
|
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
|
||||||
|
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
|
||||||
|
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
|
||||||
|
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
|
||||||
|
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
|
||||||
|
and provider-agnostic; it stays at the protocol and legacy edges.
|
||||||
|
- **Provider-initiated changes.** A setting the provider changes on its own is an
|
||||||
|
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
|
||||||
|
|
||||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||||
server and tool identity without approving native tools.
|
server and tool identity without approving native tools.
|
||||||
@@ -89,7 +133,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
|||||||
|
|
||||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||||
|
|
||||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||||
|
|
||||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||||
|
|
||||||
@@ -115,7 +159,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
|||||||
|
|
||||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||||
|
|
||||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
|
||||||
|
|
||||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||||
|
|
||||||
@@ -478,7 +522,7 @@ interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
readonly features?: AgentFeature[];
|
readonly controls?: AgentOptionControl[];
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||||
@@ -500,13 +544,15 @@ interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
|
||||||
|
|
||||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|||||||
@@ -6,15 +6,25 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
|
|||||||
|
|
||||||
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
||||||
|
|
||||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||||
|
|
||||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
|
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
|
||||||
|
|
||||||
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
||||||
|
|
||||||
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
||||||
|
|
||||||
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
|
## Character widths
|
||||||
|
|
||||||
|
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨👩👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
|
||||||
|
|
||||||
|
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
|
||||||
|
|
||||||
|
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
|
||||||
|
|
||||||
|
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
|
||||||
|
|
||||||
|
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
|
||||||
|
|
||||||
## Ink outside the row
|
## Ink outside the row
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||||
|
|
||||||
|
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||||
|
|
||||||
|
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Insert known agent commands from replies into the Composer draft
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||||
|
|
||||||
|
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||||
|
|
||||||
|
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Insert known agent commands from replies into the Composer draft
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||||
|
|
||||||
|
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||||
|
|
||||||
|
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Insert known agent commands from replies into the Composer draft
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||||
|
|
||||||
|
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||||
|
|
||||||
|
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Insert known agent commands from replies into the Composer draft
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
|||||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||||
# disable the hardened runtime that requires a matching signature.
|
# disable the hardened runtime that requires a matching signature.
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||||
../../node_modules/.bin/electron-builder \
|
npm exec --offline -- electron-builder \
|
||||||
--config electron-builder.yml \
|
--config electron-builder.yml \
|
||||||
--dir \
|
--dir \
|
||||||
--mac \
|
--mac \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
|
||||||
|
|||||||
Generated
+744
-921
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -131,6 +131,7 @@
|
|||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
|
"electron-builder": "26.18.0",
|
||||||
"expo-module-scripts": "^5.0.8",
|
"expo-module-scripts": "^5.0.8",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
@@ -151,6 +152,7 @@
|
|||||||
"expo": "~57.0.30",
|
"expo": "~57.0.30",
|
||||||
"expo-router": "~57.0.28",
|
"expo-router": "~57.0.28",
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
|
"pkijs": "3.4.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.3",
|
"react-native": "0.86.3",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export default {
|
|||||||
searchPaths: ["../../node_modules", "./node_modules"],
|
searchPaths: ["../../node_modules", "./node_modules"],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
withPasteInput,
|
withPasteInput,
|
||||||
withAndroidScroll,
|
withAndroidScroll,
|
||||||
|
|||||||
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
// Scene startup is the integration boundary for the iOS keyboard window.
|
||||||
|
describe("hardware keyboard scene integration", () => {
|
||||||
|
it("installs observation after Expo creates the scene window and is idempotent", () => {
|
||||||
|
const {
|
||||||
|
configureHardwareKeyboardAppDelegate,
|
||||||
|
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
|
||||||
|
const source =
|
||||||
|
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
|
||||||
|
const configured = configureHardwareKeyboardAppDelegate(source);
|
||||||
|
expect(configured).toContain("import PaseoHardwareKeyboard");
|
||||||
|
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
|
||||||
|
expect(configured.indexOf("super.scene")).toBeLessThan(
|
||||||
|
configured.indexOf(".observing(window)"),
|
||||||
|
);
|
||||||
|
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
addProjectFlow,
|
addProjectFlow,
|
||||||
|
chooseAddHostFromProjectFlow,
|
||||||
addProjectFlowBack,
|
addProjectFlowBack,
|
||||||
addProjectFlowHost,
|
addProjectFlowHost,
|
||||||
addProjectFlowInput,
|
addProjectFlowInput,
|
||||||
@@ -14,11 +15,14 @@ import {
|
|||||||
expectNewWorkspaceForAddedProject,
|
expectNewWorkspaceForAddedProject,
|
||||||
expectAddProjectResultVisible,
|
expectAddProjectResultVisible,
|
||||||
navigateAddProjectResults,
|
navigateAddProjectResults,
|
||||||
|
navigateAndDismissAddProject,
|
||||||
|
watchAddProjectRenderWarnings,
|
||||||
openAddProjectFlow,
|
openAddProjectFlow,
|
||||||
openAddProjectHostSelection,
|
openAddProjectHostSelection,
|
||||||
} from "../support/helpers/add-project-flow";
|
} from "../support/helpers/add-project-flow";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
|
disconnectAllHostsAndReload,
|
||||||
addConnectedHostAndReload,
|
addConnectedHostAndReload,
|
||||||
addConnectedHostsAndReload,
|
addConnectedHostsAndReload,
|
||||||
addOfflineHostAndReload,
|
addOfflineHostAndReload,
|
||||||
@@ -69,6 +73,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
|||||||
test.describe("Add Project command-center flow", () => {
|
test.describe("Add Project command-center flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await disconnectAllHostsAndReload(page);
|
||||||
|
await openAddProjectHostSelection(page);
|
||||||
|
const originalUrl = page.url();
|
||||||
|
await chooseAddHostFromProjectFlow(page);
|
||||||
|
await expect(addProjectFlow(page)).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
});
|
||||||
|
|
||||||
test("method selection shows the daemon's available project sources without search", async ({
|
test("method selection shows the daemon's available project sources without search", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -131,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const renderWarnings = watchAddProjectRenderWarnings(page);
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addConnectedHostAndReload(page, {
|
await addConnectedHostAndReload(page, {
|
||||||
serverId: secondaryHost.serverId,
|
serverId: secondaryHost.serverId,
|
||||||
@@ -168,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expectAddProjectPage(page, "host");
|
await expectAddProjectPage(page, "host");
|
||||||
await page.keyboard.press("Escape");
|
await navigateAndDismissAddProject(page);
|
||||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||||
|
expect(renderWarnings).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
|||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
await openSettingsSection(page, "appearance");
|
await openSettingsSection(page, "appearance");
|
||||||
|
|
||||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
|
||||||
await themeTrigger.click();
|
await expect(
|
||||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
|
||||||
|
).toHaveAccessibleName("System");
|
||||||
|
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { writeFile } from "node:fs/promises";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect, type Page } from "../support/fixtures";
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
|||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||||
|
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||||
|
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||||
|
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
testInfo.setTimeout(120_000);
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "nested-file-link-",
|
||||||
|
title: "Nested file links",
|
||||||
|
featureValues: {
|
||||||
|
mockStreamingAssistantResponse:
|
||||||
|
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||||
|
mockStreamingAssistantIntervalMs: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||||
|
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||||
|
await writeFile(
|
||||||
|
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||||
|
"# Nested file content\n\nDetails\n",
|
||||||
|
);
|
||||||
|
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||||
|
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||||
|
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||||
|
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||||
|
await returnToNestedLinks(page);
|
||||||
|
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||||
|
await returnToNestedLinks(page);
|
||||||
|
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||||
|
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import {
|
||||||
|
expectWorkspaceShownIdle,
|
||||||
|
finishAndArchiveAgent,
|
||||||
|
startBackgroundAgent,
|
||||||
|
} from "../support/helpers/background-agents";
|
||||||
|
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
expectSubagentRowGone,
|
||||||
|
expectSubagentRowVisible,
|
||||||
|
openSubagentsTrack,
|
||||||
|
seedParentWithSubagent,
|
||||||
|
seedSubagent,
|
||||||
|
} from "../support/helpers/subagents";
|
||||||
|
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
test.describe("Background agents", () => {
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "background-agents-",
|
||||||
|
title: "Classifier workspace",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await workspace?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const agents = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Feature agent",
|
||||||
|
childTitle: "Visible helper",
|
||||||
|
});
|
||||||
|
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await expectSubagentRowVisible(page, agents.child.id);
|
||||||
|
|
||||||
|
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||||
|
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||||
|
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||||
|
// its row shows, the app has processed the classifier too.
|
||||||
|
const marker = await seedSubagent(workspace, {
|
||||||
|
parentId: agents.parent.id,
|
||||||
|
title: "Ordering marker",
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||||
|
await expectSubagentRowVisible(page, marker.id);
|
||||||
|
await expectSubagentRowGone(page, classifier.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||||
|
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("the plugin reads its finish and archives it", async () => {
|
||||||
|
await finishAndArchiveAgent(workspace, classifier.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||||
|
await expectSubagentRowGone(page, classifier.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { Locator } from "@playwright/test";
|
||||||
import { test, expect, type Page } from "../support/fixtures";
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||||
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
|
||||||
|
const items = menu.getByRole("menuitem");
|
||||||
|
await expect(items).toHaveCount(labels.length);
|
||||||
|
for (const [index, label] of labels.entries()) {
|
||||||
|
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||||
cleanupTasks.push(async () => {
|
cleanupTasks.push(async () => {
|
||||||
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
|
|||||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||||
|
|
||||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
|
||||||
"Pull",
|
|
||||||
"Push",
|
|
||||||
"Pull and push",
|
|
||||||
"Archive workspace",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||||
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
|
|||||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||||
|
|
||||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
|
||||||
"Push",
|
|
||||||
"Pull",
|
|
||||||
"Pull and push",
|
|
||||||
"Archive workspace",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
import {
|
||||||
|
attachFileFromMenu,
|
||||||
|
composerLocator,
|
||||||
|
expectComposerVisible,
|
||||||
|
} from "../support/helpers/composer";
|
||||||
import {
|
import {
|
||||||
openAgentRoute,
|
openAgentRoute,
|
||||||
seedMockAgentWorkspace,
|
seedMockAgentWorkspace,
|
||||||
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Composer autocomplete", () => {
|
test.describe("Composer autocomplete", () => {
|
||||||
|
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||||
|
const requests = recordCommandRequestsByAgentId(page);
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "suggested-command-",
|
||||||
|
title: "Suggested commands",
|
||||||
|
initialPrompt: "Suggest `/mock` without running it",
|
||||||
|
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||||
|
await expect(message).toBeVisible();
|
||||||
|
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
await attachFileFromMenu(page, {
|
||||||
|
name: "note.txt",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("attachment"),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||||
|
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||||
|
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(composerLocator(page)).toBeFocused();
|
||||||
|
await message.getByRole("link", { name: "Run mock" }).click();
|
||||||
|
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(composerLocator(page)).toBeFocused();
|
||||||
|
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||||
|
await composerLocator(page).fill("draft");
|
||||||
|
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||||
|
element.setSelectionRange(0, 0),
|
||||||
|
);
|
||||||
|
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||||
|
await composerLocator(page).pressSequentially("argument");
|
||||||
|
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||||
|
await composerLocator(page).fill("/");
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||||
|
).toContainText("/mock");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
submitMessage,
|
fillComposerDraft,
|
||||||
typeIntoFocusedComposer,
|
typeIntoFocusedComposer,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
|||||||
return page.getByTestId("user-message");
|
return page.getByTestId("user-message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendDraftWhenReady(page: Page) {
|
||||||
|
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||||
|
await pressInComposer(page, "Enter");
|
||||||
|
}
|
||||||
|
|
||||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||||
await submitMessage(page, text);
|
await fillComposerDraft(page, text);
|
||||||
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||||
await expectComposerDraft(page, "");
|
await expectComposerDraft(page, "");
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
|||||||
await pressInComposer(page, "ArrowUp");
|
await pressInComposer(page, "ArrowUp");
|
||||||
await expectComposerDraft(page, "add tests for login");
|
await expectComposerDraft(page, "add tests for login");
|
||||||
|
|
||||||
await pressInComposer(page, "Enter");
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
|||||||
await expect(details).toHaveCount(0);
|
await expect(details).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("context details stay open while the chat streams", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "context-window-streaming-",
|
||||||
|
title: "Context details while streaming",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
await submitMessageWithButton(page, "Stream while the context details are open.");
|
||||||
|
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||||
|
await expect(stop).toBeVisible();
|
||||||
|
const details = await pressContextWindowMeter(page, /^Context window/);
|
||||||
|
await expect(stop).toBeVisible();
|
||||||
|
await qaScreenshot(page, "context-details-while-streaming");
|
||||||
|
|
||||||
|
await expect(stop).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(details).toBeVisible();
|
||||||
|
await qaScreenshot(page, "context-details-after-streaming");
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||||
const usage = await scriptAgentUsage(page);
|
const usage = await scriptAgentUsage(page);
|
||||||
await page.setViewportSize(DESKTOP);
|
await page.setViewportSize(DESKTOP);
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||||
|
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPageHandlesFragment(
|
||||||
|
page: Page,
|
||||||
|
handler: "Document" | "Window",
|
||||||
|
): Promise<void> {
|
||||||
|
const preview = htmlPreview(page);
|
||||||
|
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||||
|
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).not.toBeInViewport();
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||||
name: view,
|
name: view,
|
||||||
@@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
await expect(preview.host).toBeVisible();
|
await expect(preview.host).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "html-fragments-" });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.repoPath, "fragments.html"),
|
||||||
|
`<!doctype html><html><body>
|
||||||
|
<a href="#section">Jump to section</a>
|
||||||
|
<div style="height: 1200px"></div>
|
||||||
|
<h2 id="section">Target section</h2>
|
||||||
|
</body></html>`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "fragments.html");
|
||||||
|
|
||||||
|
const preview = htmlPreview(page);
|
||||||
|
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
|
||||||
|
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
|
||||||
|
await preview.document.getByRole("link", { name: "Jump to section" }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.repoPath, "handled.html"),
|
||||||
|
`<!doctype html><html><body>
|
||||||
|
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
|
||||||
|
<output>Waiting for click</output>
|
||||||
|
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||||
|
</body></html>`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "handled.html");
|
||||||
|
|
||||||
|
const preview = htmlPreview(page);
|
||||||
|
await preview.document.getByRole("link", { name: "Handled jump" }).click();
|
||||||
|
await expect(preview.document.locator("output")).toHaveText("Page handled click");
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).not.toBeInViewport();
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||||
|
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.repoPath, "defaults.html"),
|
||||||
|
`<!doctype html><html><body>
|
||||||
|
<nav style="position: fixed; top: 0; background: white">
|
||||||
|
<a href="#section" id="document-handler">Document handled jump</a>
|
||||||
|
<a href="#section" id="window-handler">Window handled jump</a>
|
||||||
|
<a href=" \t#section\n">Whitespace jump</a>
|
||||||
|
<a href="#100%">Percent jump</a>
|
||||||
|
<output>Waiting for click</output>
|
||||||
|
</nav>
|
||||||
|
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||||
|
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||||
|
<div style="height: 1200px"></div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
if (event.target.id === 'document-handler') {
|
||||||
|
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('click', function(event) {
|
||||||
|
if (event.target.id === 'window-handler') {
|
||||||
|
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script></body></html>`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "defaults.html");
|
||||||
|
|
||||||
|
const preview = htmlPreview(page);
|
||||||
|
await expectPageHandlesFragment(page, "Document");
|
||||||
|
await expectPageHandlesFragment(page, "Window");
|
||||||
|
|
||||||
|
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await followHtmlPreviewLink(page, "Percent jump");
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.repoPath, "targets.html"),
|
||||||
|
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
|
||||||
|
<nav style="position: fixed; top: 0; background: white">
|
||||||
|
<a href="#section"><span>Nested jump</span></a>
|
||||||
|
<a href="#legacy">Named jump</a>
|
||||||
|
<a href="#encoded%20section">Encoded jump</a>
|
||||||
|
<a href="#missing">Missing jump</a>
|
||||||
|
<a href="#">Empty jump</a>
|
||||||
|
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
|
||||||
|
</nav>
|
||||||
|
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||||
|
<div style="height: 1200px"></div><a name="legacy">Named target</a>
|
||||||
|
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
|
||||||
|
<div style="height: 1200px"></div>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
|
||||||
|
function modifiedClick(event) {
|
||||||
|
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
|
document.getElementById('modified').textContent = String(event.defaultPrevented);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('click', modifiedClick);
|
||||||
|
window.addEventListener('auxclick', modifiedClick);
|
||||||
|
window.addEventListener('contextmenu', modifiedClick);
|
||||||
|
</script></body></html>`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openWorkspaceFile(page, "targets.html");
|
||||||
|
|
||||||
|
const preview = htmlPreview(page);
|
||||||
|
const body = preview.document.locator("body");
|
||||||
|
const nested = preview.document.getByText("Nested jump", { exact: true });
|
||||||
|
await nested.click();
|
||||||
|
await expect(preview.document.locator("output").first()).toHaveText("#section");
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
|
||||||
|
|
||||||
|
await body.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await nested.click();
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Target section" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
|
||||||
|
await preview.document.getByRole("link", { name: "Named jump" }).click();
|
||||||
|
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
|
||||||
|
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
|
||||||
|
|
||||||
|
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
|
||||||
|
await expect(
|
||||||
|
preview.document.getByRole("heading", { name: "Encoded target" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||||
|
const scrollY = await body.evaluate(() => window.scrollY);
|
||||||
|
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||||
|
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||||
|
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||||
|
|
||||||
|
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||||
|
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
|
||||||
|
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
|
||||||
|
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
|
||||||
|
await nested.click({ modifiers: [modifier] });
|
||||||
|
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||||
|
}
|
||||||
|
await nested.click({ button: "middle" });
|
||||||
|
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||||
|
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||||
|
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { test } from "../support/fixtures";
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
collapseFolder,
|
collapseFolder,
|
||||||
expandFolder,
|
expandFolder,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
openFileExplorer,
|
openFileExplorer,
|
||||||
openFileFromExplorer,
|
openFileFromExplorer,
|
||||||
} from "../support/helpers/file-explorer";
|
} from "../support/helpers/file-explorer";
|
||||||
|
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
@@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => {
|
|||||||
await expectExplorerEntryVisible(page, "guide.md");
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
await collapseFolder(page, "docs");
|
||||||
|
await expectExplorerEntryHidden(page, "guide.md");
|
||||||
|
await createFileOutsidePaseo("docs/NEW.md");
|
||||||
|
await refreshFiles(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
await expectExplorerEntryVisible(page, "NEW.md");
|
||||||
|
|
||||||
|
await createFileOutsidePaseo("docs/NEXT.md");
|
||||||
|
await refreshFiles(page);
|
||||||
|
await expectExplorerEntryVisible(page, "NEXT.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
|
||||||
|
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
|
||||||
|
await refresh.click();
|
||||||
|
await expect(refresh).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFileOutsidePaseo(filePath: string): Promise<void> {
|
||||||
|
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await reply.waitUntilHeld();
|
||||||
|
await collapseFolder(page, "docs");
|
||||||
|
await createFileOutsidePaseo("docs/DELAYED.md");
|
||||||
|
await refreshFiles(page);
|
||||||
|
await reply.release();
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await expectExplorerEntryVisible(page, "DELAYED.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||||
|
await page.reload();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await reply.waitUntilHeld();
|
||||||
|
await refreshFiles(page);
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
await reply.release();
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
});
|
||||||
|
|||||||
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
|||||||
|
|
||||||
await entry("renamed.txt").click({ button: "right" });
|
await entry("renamed.txt").click({ button: "right" });
|
||||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||||
const deleteLabelColor = await deleteAction
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const labelColor = await deleteAction
|
||||||
.getByText("Delete", { exact: true })
|
.getByText("Delete", { exact: true })
|
||||||
.evaluate((element) => getComputedStyle(element).color);
|
.evaluate((element) => getComputedStyle(element).color);
|
||||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
const iconColor = await deleteAction
|
||||||
|
.locator("svg")
|
||||||
|
.evaluate((element) => getComputedStyle(element).stroke);
|
||||||
|
return labelColor !== "" && iconColor === labelColor;
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||||
page.once("dialog", async (dialog) => {
|
page.once("dialog", async (dialog) => {
|
||||||
const message = dialog.message();
|
const message = dialog.message();
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ for (const folder of [
|
|||||||
"docs/guides",
|
"docs/guides",
|
||||||
"docs/guides/",
|
"docs/guides/",
|
||||||
"docs/指南",
|
"docs/指南",
|
||||||
|
"examples/next.js/",
|
||||||
|
"examples/next.js",
|
||||||
]) {
|
]) {
|
||||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||||
const session = await seedMockAgentWorkspace({
|
const session = await seedMockAgentWorkspace({
|
||||||
@@ -92,6 +94,9 @@ for (const folder of [
|
|||||||
try {
|
try {
|
||||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||||
|
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||||
|
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||||
|
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||||
await session.client.waitForFinish(session.agentId, 30_000);
|
await session.client.waitForFinish(session.agentId, 30_000);
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
|
|||||||
});
|
});
|
||||||
const hostTrigger = page.getByTestId("host-picker-trigger");
|
const hostTrigger = page.getByTestId("host-picker-trigger");
|
||||||
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
||||||
|
const originalUrl = page.url();
|
||||||
await hostTrigger.click();
|
await hostTrigger.click();
|
||||||
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
||||||
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
||||||
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
} finally {
|
} finally {
|
||||||
await seeded.cleanup();
|
await seeded.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, metroTest as test } from "../support/fixtures";
|
||||||
|
import {
|
||||||
|
addProjectFromSetup,
|
||||||
|
continueSetup,
|
||||||
|
expectSetupStep,
|
||||||
|
finishSetup,
|
||||||
|
goToSetupStep,
|
||||||
|
hostGivesAgentsPaseoTools,
|
||||||
|
openAppAsNewUser,
|
||||||
|
openRelayPairingFromSetup,
|
||||||
|
closeRelayPairing,
|
||||||
|
openTailscaleDocsFromSetup,
|
||||||
|
paseoToolsSwitch,
|
||||||
|
pickTheme,
|
||||||
|
projectSuggestion,
|
||||||
|
projectSuggestionRow,
|
||||||
|
setupModal,
|
||||||
|
setupPrimaryButton,
|
||||||
|
showSetupAgainFromSettings,
|
||||||
|
skipSetup,
|
||||||
|
startSetupHost,
|
||||||
|
} from "../support/helpers/onboarding";
|
||||||
|
import {
|
||||||
|
chooseCustomSkills,
|
||||||
|
openSkillSelection,
|
||||||
|
saveSkillSelection,
|
||||||
|
} from "../support/helpers/agent-skills";
|
||||||
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
|
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||||
|
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||||
|
|
||||||
|
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||||
|
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
|
||||||
|
|
||||||
|
test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||||
|
const host = await startSetupHost("fresh");
|
||||||
|
const repo = await createTempGitRepo("onboarding-folder-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
|
||||||
|
await test.step("setup opens on Appearance and a theme applies at once", async () => {
|
||||||
|
await expectSetupStep(page, "Welcome to Paseo");
|
||||||
|
await pickTheme(page, "Midnight");
|
||||||
|
await expect(
|
||||||
|
setupModal(page).getByText("Let's start by picking a look", { exact: true }),
|
||||||
|
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||||
|
await continueSetup(page);
|
||||||
|
await expectSetupStep(page, "Your agents");
|
||||||
|
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||||
|
await continueSetup(page);
|
||||||
|
await expectSetupStep(page, "Add your projects");
|
||||||
|
await expect(
|
||||||
|
setupModal(page).getByText("No recent projects found", { exact: true }),
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
await addProjectFromSetup(page, repo.path);
|
||||||
|
await expectSetupStep(page, "Add your projects");
|
||||||
|
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
|
||||||
|
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Orchestration starts with its switches on", async () => {
|
||||||
|
await continueSetup(page);
|
||||||
|
await expectSetupStep(page, "Let agents use Paseo");
|
||||||
|
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||||
|
await expect(
|
||||||
|
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Skip setup closes it for good", async () => {
|
||||||
|
await skipSetup(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({
|
||||||
|
hasText: path.basename(repo.path),
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(setupModal(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||||
|
const repo = await createTempGitRepo("onboarding-history-");
|
||||||
|
const repoName = path.basename(repo.path);
|
||||||
|
const subdirectory = path.join(repo.path, "packages", "web");
|
||||||
|
await mkdir(subdirectory, { recursive: true });
|
||||||
|
const worktree = `${repo.path}-fix`;
|
||||||
|
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
|
||||||
|
const scratch = await createTempDirectory("onboarding-scratch-");
|
||||||
|
const scratchName = path.basename(scratch.path);
|
||||||
|
const host = await startSetupHost("history", {
|
||||||
|
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
|
||||||
|
withBrokenProvider: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
await expectSetupStep(page, "Welcome to Paseo");
|
||||||
|
await goToSetupStep(page, "Projects");
|
||||||
|
await expectSetupStep(page, "Add your projects");
|
||||||
|
|
||||||
|
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
|
||||||
|
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
|
||||||
|
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||||
|
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
|
||||||
|
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
|
||||||
|
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
|
||||||
|
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
|
||||||
|
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
setupModal(page).getByRole("button", { name: "Add project", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
|
||||||
|
await projectSuggestion(page, repoName).click();
|
||||||
|
await projectSuggestion(page, scratchName).click();
|
||||||
|
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
|
||||||
|
await projectSuggestion(page, repoName).click();
|
||||||
|
await projectSuggestion(page, scratchName).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a folder that cannot be added keeps the user on the step", async () => {
|
||||||
|
await scratch.cleanup();
|
||||||
|
await setupPrimaryButton(page, "Add 2 projects").click();
|
||||||
|
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectSetupStep(page, "Add your projects");
|
||||||
|
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||||
|
await expect(projectSuggestionRow(page, scratchName)).toContainText("Directory not found");
|
||||||
|
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("with nothing checked, Continue moves on without adding", async () => {
|
||||||
|
await projectSuggestion(page, scratchName).click();
|
||||||
|
await setupPrimaryButton(page, "Continue").click();
|
||||||
|
await expectSetupStep(page, "Let agents use Paseo");
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
await rm(worktree, { recursive: true, force: true });
|
||||||
|
await repo.cleanup();
|
||||||
|
await scratch.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
||||||
|
const host = await startSetupHost("existing");
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(setupModal(page)).toHaveCount(0);
|
||||||
|
|
||||||
|
await showSetupAgainFromSettings(page);
|
||||||
|
await expectSetupStep(page, "Welcome to Paseo");
|
||||||
|
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
await host.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
await expectSetupStep(page, "Welcome to Paseo");
|
||||||
|
|
||||||
|
await test.step("Paseo tools start on in setup and Continue turns them on", async () => {
|
||||||
|
await goToSetupStep(page, "Orchestration");
|
||||||
|
await expectSetupStep(page, "Let agents use Paseo");
|
||||||
|
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||||
|
await continueSetup(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
|
||||||
|
await expectSetupStep(page, "Take your agents with you");
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
|
||||||
|
expect(await openTailscaleDocsFromSetup(page)).toBe(
|
||||||
|
"https://paseo.sh/docs/connectivity#tailscale",
|
||||||
|
);
|
||||||
|
const pairing = await openRelayPairingFromSetup(page);
|
||||||
|
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||||
|
await enableRelayAndExpectOffer(page);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
|
||||||
|
await closeRelayPairing(pairing);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
|
||||||
|
await finishSetup(page);
|
||||||
|
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||||
|
await openSettingsHostSection(page, host.serverId, "agents");
|
||||||
|
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
|
||||||
|
const host = await startSetupHost("no-skills");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
await expectSetupStep(page, "Welcome to Paseo");
|
||||||
|
await goToSetupStep(page, "Orchestration");
|
||||||
|
await expectSetupStep(page, "Let agents use Paseo");
|
||||||
|
|
||||||
|
await openSkillSelection(page);
|
||||||
|
await chooseCustomSkills(page, []);
|
||||||
|
await saveSkillSelection(page);
|
||||||
|
const skillsSwitch = setupModal(page).getByRole("switch", {
|
||||||
|
name: "Orchestration skills",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(skillsSwitch).not.toBeChecked();
|
||||||
|
|
||||||
|
await continueSetup(page);
|
||||||
|
await expectSetupStep(page, "Take your agents with you");
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const host = await startSetupHost("compact");
|
||||||
|
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||||
|
// The compact sheet is the only surface on screen, and Orchestration is its last step.
|
||||||
|
const step = (title: string) =>
|
||||||
|
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAppAsNewUser(page, host);
|
||||||
|
await step("Welcome to Paseo");
|
||||||
|
await primary("Continue").click();
|
||||||
|
await step("Your agents");
|
||||||
|
await primary("Continue").click();
|
||||||
|
await step("Add your projects");
|
||||||
|
await primary("Continue").click();
|
||||||
|
|
||||||
|
// The setup host starts with Paseo tools off; the switch starts on.
|
||||||
|
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
|
||||||
|
await step("Let agents use Paseo");
|
||||||
|
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||||
|
// Skills are not installed, so leaving them off has nothing to apply.
|
||||||
|
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
|
||||||
|
await skillsSwitch.click();
|
||||||
|
await expect(skillsSwitch).not.toBeChecked();
|
||||||
|
gate.holdNextClientRequest("set_daemon_config_request");
|
||||||
|
await primary("Finish").click();
|
||||||
|
|
||||||
|
// While the choices apply, none of them can change.
|
||||||
|
await gate.waitForHeldClientRequest();
|
||||||
|
await expect(paseoToolsSwitch(page)).toBeDisabled();
|
||||||
|
await expect(skillsSwitch).toBeDisabled();
|
||||||
|
gate.releaseHeldClientRequest();
|
||||||
|
|
||||||
|
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
|
||||||
|
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
|||||||
await buttons.verifyObservationLifetime();
|
await buttons.verifyObservationLifetime();
|
||||||
await buttons.openWideMenusAndPopovers();
|
await buttons.openWideMenusAndPopovers();
|
||||||
await buttons.runAndUpdateActions();
|
await buttons.runAndUpdateActions();
|
||||||
|
await buttons.keepTrackSpaceWithoutPills();
|
||||||
await buttons.openCompactSheets();
|
await buttons.openCompactSheets();
|
||||||
await buttons.useCompactWorkspaceMenu();
|
await buttons.useCompactWorkspaceMenu();
|
||||||
await buttons.hideAndUnloadOpenButtons();
|
await buttons.hideAndUnloadOpenButtons();
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
returnAndReopenSettings,
|
returnAndReopenSettings,
|
||||||
returnToPluginsPage,
|
returnToPluginsPage,
|
||||||
expectSettingsUnavailable,
|
expectSettingsUnavailable,
|
||||||
|
openPluginsPage,
|
||||||
|
expectActionsMenuWithoutSettings,
|
||||||
|
expectNoSettingsCogWithoutScreens,
|
||||||
|
openTabbedSettings,
|
||||||
|
selectSettingsTab,
|
||||||
|
openTabbedSettingsLink,
|
||||||
|
expectSettingsTooltip,
|
||||||
|
expectNamedPluginRow,
|
||||||
} from "../support/helpers/plugin-settings";
|
} from "../support/helpers/plugin-settings";
|
||||||
|
|
||||||
test("display preferences survive a reload", async ({ page }) => {
|
test("display preferences survive a reload", async ({ page }) => {
|
||||||
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
|||||||
await returnToPluginsPage(page);
|
await returnToPluginsPage(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plugin settings open from the cog and show a tab per 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 actions 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a settings screen link opens its tab", async ({ page }) => {
|
||||||
|
await openTabbedSettingsLink(page, "Advanced");
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("compact plugin settings", () => {
|
test.describe("compact plugin settings", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
|||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
BOTS_PLUGIN_ID,
|
BOTS_PLUGIN_ID,
|
||||||
COMPACT,
|
COMPACT,
|
||||||
@@ -27,6 +28,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
|||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||||
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
@@ -133,6 +135,30 @@ function sidebarFooter(page: Page): Locator {
|
|||||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPluginCatalogResponse(message: string | Buffer): boolean {
|
||||||
|
if (typeof message !== "string") return false;
|
||||||
|
const frame = JSON.parse(message);
|
||||||
|
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function holdNextPluginCatalogResponse(page: Page) {
|
||||||
|
const response = Promise.withResolvers<() => void>();
|
||||||
|
let holdCatalog = true;
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||||
|
const server = socket.connectToServer();
|
||||||
|
socket.onMessage((message) => server.send(message));
|
||||||
|
server.onMessage((message) => {
|
||||||
|
if (holdCatalog && isPluginCatalogResponse(message)) {
|
||||||
|
holdCatalog = false;
|
||||||
|
response.resolve(() => socket.send(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { response: response.promise };
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Plugin sidebar items", () => {
|
test.describe("Plugin sidebar items", () => {
|
||||||
let workspaceId: string;
|
let workspaceId: string;
|
||||||
let cleanup: () => Promise<void>;
|
let cleanup: () => Promise<void>;
|
||||||
@@ -151,6 +177,69 @@ test.describe("Plugin sidebar items", () => {
|
|||||||
await cleanup();
|
await cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 600 });
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||||
|
await expect(botRow(page, "status")).toBeAttached();
|
||||||
|
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
|
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||||
|
|
||||||
|
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||||
|
const before = await boxOf(workspace);
|
||||||
|
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||||
|
await page.mouse.wheel(0, 800);
|
||||||
|
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||||
|
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||||
|
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||||
|
await botRow(page, "status").click();
|
||||||
|
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||||
|
await workspace.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(workspace).not.toBeInViewport();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||||
|
await page.setViewportSize(WIDE);
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
const body = page.getByText("Deploys screen body", { exact: true });
|
||||||
|
await expect(body).toBeVisible();
|
||||||
|
const screenUrl = page.url();
|
||||||
|
const catalog = await holdNextPluginCatalogResponse(page);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
const releaseCatalog = await catalog.response;
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(body).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
releaseCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(body).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(screenUrl);
|
||||||
|
});
|
||||||
|
|
||||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||||
|
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import {
|
||||||
|
switchWorkspaceViaSidebar,
|
||||||
|
waitForWorkspaceInSidebar,
|
||||||
|
} from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
|
async function runCommand(page: Page, title: string): Promise<void> {
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill(title);
|
||||||
|
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||||
|
await expect(panel).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
|
||||||
|
async function withExample(
|
||||||
|
page: Page,
|
||||||
|
example: { name: string; id: string },
|
||||||
|
exercise: (workspace: SeededWorkspace) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
|
const previous = await client.getDaemonConfig();
|
||||||
|
const copy = await copyPluginExample(example.name);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
|
||||||
|
try {
|
||||||
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
await client.installDirectoryPlugin(copy.directory);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await gotoAppShell(page);
|
||||||
|
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||||
|
await waitForWorkspaceInSidebar(page, target);
|
||||||
|
await switchWorkspaceViaSidebar({ page, ...target });
|
||||||
|
await exercise(workspace);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.removePlugin(example.id);
|
||||||
|
} finally {
|
||||||
|
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||||
|
await workspace.cleanup();
|
||||||
|
await client.close();
|
||||||
|
await copy.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("plugin subscriptions", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
|
||||||
|
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
|
||||||
|
await runCommand(page, "Open live changes");
|
||||||
|
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
|
||||||
|
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
|
||||||
|
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
|
||||||
|
await runCommand(page, "Open media stream");
|
||||||
|
await expect(page.getByText("live", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||||
import { openSettingsSection } from "../support/helpers/settings";
|
import { openSettingsSection } from "../support/helpers/settings";
|
||||||
@@ -69,8 +68,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
|||||||
await openSettingsSection(page, "appearance");
|
await openSettingsSection(page, "appearance");
|
||||||
|
|
||||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
|
||||||
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
|
||||||
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath("plugin-theme-picker.png"),
|
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||||
@@ -79,14 +77,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step("a contributed light theme uses the light palette", async () => {
|
await test.step("a contributed light theme uses the light palette", async () => {
|
||||||
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
|
||||||
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
await latteItem.click();
|
||||||
|
await expect(latteItem).toBeChecked();
|
||||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||||
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await mochaItem.click();
|
await mochaItem.click();
|
||||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
await expect(mochaItem).toBeChecked();
|
||||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath("plugin-theme-applied.png"),
|
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||||
@@ -95,15 +93,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
|||||||
|
|
||||||
await test.step("the selection survives a reload", async () => {
|
await test.step("the selection survives a reload", async () => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("removing the plugin falls back to the default theme", async () => {
|
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||||
await client.removePlugin(PLUGIN_ID);
|
await client.removePlugin(PLUGIN_ID);
|
||||||
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
@@ -147,15 +143,7 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
test("reaches the last theme when a plugin contributes more themes than fit", async ({
|
||||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
|
||||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
|
||||||
await firstTheme.hover();
|
|
||||||
await page.mouse.wheel(0, 4000);
|
|
||||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||||
@@ -175,15 +163,16 @@ test("scrolls to the last theme when a plugin contributes more themes than fit",
|
|||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
await openSettingsSection(page, "appearance");
|
await openSettingsSection(page, "appearance");
|
||||||
|
|
||||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
|
||||||
await scrollThemeMenuToLastTheme(page);
|
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
|
||||||
|
await lastTheme.scrollIntoViewIfNeeded();
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
await lastTheme.click();
|
||||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
await expect(lastTheme).toBeChecked();
|
||||||
} finally {
|
} finally {
|
||||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||||
await client
|
await client
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting description text never answers, and the label still does", async ({
|
test("selecting description text keeps the question open", async ({ page, context }) => {
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
const { session, card } = await openLinkedQuestions(page, context);
|
const { session, card } = await openLinkedQuestions(page, context);
|
||||||
try {
|
try {
|
||||||
@@ -120,16 +117,13 @@ test.describe("Question option text", () => {
|
|||||||
|
|
||||||
await dragAcross(page, description);
|
await dragAcross(page, description);
|
||||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||||
await expectChecked(done, false);
|
|
||||||
|
|
||||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||||
position: { x: 4, y: 4 },
|
position: { x: 4, y: 4 },
|
||||||
});
|
});
|
||||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||||
await expectChecked(done, false);
|
await expectChecked(done, true);
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await done.getByText("Done", { exact: true }).click();
|
|
||||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||||
await openQuestion(page, { index: 1, total: 2 });
|
await openQuestion(page, { index: 1, total: 2 });
|
||||||
await expectChecked(done, true);
|
await expectChecked(done, true);
|
||||||
@@ -138,11 +132,17 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
test("double-clicking a single-select label selects once without advancing", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
const { session, card } = await openLinkedQuestions(page, context);
|
const { session, card } = await openLinkedQuestions(page, context);
|
||||||
try {
|
try {
|
||||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||||
|
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||||
|
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||||
|
await continueToNextQuestion(page);
|
||||||
|
|
||||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||||
@@ -154,14 +154,12 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multi-select description links and selection leave the option unchanged", async ({
|
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
const { session, card } = await openLinkedQuestions(page, context);
|
const { session, card } = await openLinkedQuestions(page, context);
|
||||||
try {
|
try {
|
||||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||||
|
|
||||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||||
@@ -177,18 +175,16 @@ test.describe("Question option text", () => {
|
|||||||
|
|
||||||
await dragAcross(page, description);
|
await dragAcross(page, description);
|
||||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||||
await expectChecked(lint, false);
|
|
||||||
|
|
||||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||||
position: { x: 4, y: 4 },
|
position: { x: 4, y: 4 },
|
||||||
});
|
});
|
||||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||||
await expectChecked(lint, false);
|
await expectChecked(lint, true);
|
||||||
|
|
||||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||||
await expectChecked(typecheck, true);
|
await expectChecked(typecheck, true);
|
||||||
await lint.getByText("Lint", { exact: true }).click();
|
await lint.getByText("Lint", { exact: true }).click();
|
||||||
await expectChecked(lint, true);
|
await expectChecked(lint, false);
|
||||||
} finally {
|
} finally {
|
||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
}
|
}
|
||||||
@@ -268,6 +264,7 @@ test.describe("Question prompt pagination", () => {
|
|||||||
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||||
|
|
||||||
await chooseQuestionOption(page, "App");
|
await chooseQuestionOption(page, "App");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await expectCurrentQuestion(page, {
|
await expectCurrentQuestion(page, {
|
||||||
index: 2,
|
index: 2,
|
||||||
total: TOTAL_QUESTIONS,
|
total: TOTAL_QUESTIONS,
|
||||||
@@ -288,6 +285,7 @@ test.describe("Question prompt pagination", () => {
|
|||||||
preview: 'flags.enabled("new-surface")',
|
preview: 'flags.enabled("new-surface")',
|
||||||
});
|
});
|
||||||
await chooseQuestionOption(page, "Behind feature flag");
|
await chooseQuestionOption(page, "Behind feature flag");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await expectCurrentQuestion(page, {
|
await expectCurrentQuestion(page, {
|
||||||
index: 3,
|
index: 3,
|
||||||
total: TOTAL_QUESTIONS,
|
total: TOTAL_QUESTIONS,
|
||||||
@@ -466,7 +464,9 @@ retryTest(
|
|||||||
retryTest.setTimeout(180_000);
|
retryTest.setTimeout(180_000);
|
||||||
const card = page.getByTestId("question-form-card");
|
const card = page.getByTestId("question-form-card");
|
||||||
await chooseQuestionOption(page, "App");
|
await chooseQuestionOption(page, "App");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await chooseQuestionOption(page, "Behind feature flag");
|
await chooseQuestionOption(page, "Behind feature flag");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await fillQuestionAnswer(page, {
|
await fillQuestionAnswer(page, {
|
||||||
question: SUCCESS_QUESTION,
|
question: SUCCESS_QUESTION,
|
||||||
answer: "Preserve my answers after a failed submission.",
|
answer: "Preserve my answers after a failed submission.",
|
||||||
@@ -520,7 +520,9 @@ retryTest(
|
|||||||
const card = page.getByTestId("question-form-card");
|
const card = page.getByTestId("question-form-card");
|
||||||
questionClient.releaseFirstResponse();
|
questionClient.releaseFirstResponse();
|
||||||
await chooseQuestionOption(page, "App");
|
await chooseQuestionOption(page, "App");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await chooseQuestionOption(page, "Behind feature flag");
|
await chooseQuestionOption(page, "Behind feature flag");
|
||||||
|
await continueToNextQuestion(page);
|
||||||
await fillQuestionAnswer(page, {
|
await fillQuestionAnswer(page, {
|
||||||
question: SUCCESS_QUESTION,
|
question: SUCCESS_QUESTION,
|
||||||
answer: "Start with the internal team.",
|
answer: "Start with the internal team.",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
openGlobalNewWorkspaceComposer,
|
||||||
|
fillNewWorkspaceDraft,
|
||||||
|
expectNewWorkspaceDraft,
|
||||||
|
} from "../support/helpers/new-workspace";
|
||||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
@@ -257,12 +262,15 @@ test.describe("Settings host page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
await openHostSection(page, serverId, "agents");
|
await openHostSection(page, serverId, "agents");
|
||||||
|
await expectRestartRecoveryEnabled(page, false);
|
||||||
|
await setRestartRecoveryEnabled(page, true);
|
||||||
|
await page.reload();
|
||||||
await expectRestartRecoveryEnabled(page, true);
|
await expectRestartRecoveryEnabled(page, true);
|
||||||
await expectRestartRecoveryPrompt(page, "Resume working.");
|
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||||
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
@@ -273,3 +281,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
|
|||||||
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(page).toHaveURL(/\/open-project$/);
|
||||||
|
const originalUrl = page.url();
|
||||||
|
await openSidebarAddHost(page);
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
await closeAddHostAndChangeTheme(page);
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSidebarAddHost(page: import("@playwright/test").Page) {
|
||||||
|
await page.getByRole("button", { name: "Hosts", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
|
||||||
|
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||||
|
await page.keyboard.press("Control+Alt+t");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("sidebar Add host completes without navigating", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(page).toHaveURL(/\/open-project$/);
|
||||||
|
const originalUrl = page.url();
|
||||||
|
await openSidebarAddHost(page);
|
||||||
|
await connectToTestHost(page);
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Settings Add host opens the saved host Overview", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
|
||||||
|
await expectSettingsHeader(page, "Overview");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
|
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||||
|
const originalUrl = page.url();
|
||||||
|
await openNewWorkspaceAddHost(page);
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
await connectToTestHost(page);
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
|
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
|
||||||
|
await page.getByTestId("host-picker-trigger").click();
|
||||||
|
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectToTestHost(page: import("@playwright/test").Page) {
|
||||||
|
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
|
||||||
|
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
|
||||||
|
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
fillDirectHostUri,
|
fillDirectHostUri,
|
||||||
expectDirectHostFormValues,
|
expectDirectHostFormValues,
|
||||||
expectDirectHostSslEnabled,
|
expectDirectHostSslEnabled,
|
||||||
|
expectDirectHostSslDisabled,
|
||||||
|
fillDirectHostAddress,
|
||||||
|
toggleDirectHostSsl,
|
||||||
expectDirectHostUriValue,
|
expectDirectHostUriValue,
|
||||||
expectDirectHostUriHidden,
|
expectDirectHostUriHidden,
|
||||||
expectDiagnosticsContent,
|
expectDiagnosticsContent,
|
||||||
@@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await expectAddHostMethodOptions(page);
|
await expectAddHostMethodOptions(page);
|
||||||
await selectHostConnectionType(page, "direct");
|
await selectHostConnectionType(page, "direct");
|
||||||
|
|
||||||
|
await fillDirectHostAddress(page, "example.paseo.test", "7443");
|
||||||
|
await expectDirectHostSslDisabled(page);
|
||||||
|
await toggleDirectHostSsl(page);
|
||||||
|
await expectDirectHostSslEnabled(page);
|
||||||
|
await toggleHostAdvanced(page);
|
||||||
|
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
|
||||||
|
await toggleHostAdvanced(page);
|
||||||
|
await toggleDirectHostSsl(page);
|
||||||
|
await expectDirectHostSslDisabled(page);
|
||||||
|
|
||||||
await toggleHostAdvanced(page);
|
await toggleHostAdvanced(page);
|
||||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||||
await toggleHostAdvanced(page);
|
await toggleHostAdvanced(page);
|
||||||
@@ -313,12 +326,12 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
|
|
||||||
await test.step("a dropdown owns Escape", async () => {
|
await test.step("a dropdown owns Escape", async () => {
|
||||||
await openSettingsSection(page, "appearance");
|
await openSettingsSection(page, "appearance");
|
||||||
await page.getByLabel(/Theme:/).click();
|
await page.getByLabel(/Highlight theme:/).click();
|
||||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
|
||||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
|
|||||||
await seed.cleanup();
|
await seed.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function workspaceRows(page: Page, ids: string[]) {
|
||||||
|
return page.locator(
|
||||||
|
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceRow(page: Page, workspaceId: string) {
|
||||||
|
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const { seed, ids } = await seedCompactProject();
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await selectWorkspaceSort(page, "Created");
|
||||||
|
const { rows, group } = compactProjectRows(page, seed);
|
||||||
|
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||||
|
await revealWorkspaces(group, "Show more");
|
||||||
|
await openWorkspaceRow(page, ids[9]!);
|
||||||
|
await expectWorkspaceOrder(rows, ids);
|
||||||
|
await revealWorkspaces(group, "Show less");
|
||||||
|
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await reconnectSidebar(page);
|
||||||
|
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await openWorkspaceRow(page, ids[2]!);
|
||||||
|
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
} finally {
|
||||||
|
await seed.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const { seed, ids } = await seedCompactProject();
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
const projectKey = projectEquivalenceViewKey(seed.projectKey);
|
||||||
|
await restorePreBetaOrder(
|
||||||
|
page,
|
||||||
|
projectKey,
|
||||||
|
ids.map((id) => `${getServerId()}:${id}`),
|
||||||
|
);
|
||||||
|
await selectWorkspaceSort(page, "Manual");
|
||||||
|
const rows = workspaceRows(page, ids);
|
||||||
|
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
|
||||||
|
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||||
|
await toggle.click();
|
||||||
|
await openWorkspaceRow(page, ids[9]!);
|
||||||
|
await toggle.click();
|
||||||
|
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||||
|
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
|
||||||
|
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||||
|
await reconnectSidebar(page);
|
||||||
|
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||||
|
await toggle.click();
|
||||||
|
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
|
||||||
|
} finally {
|
||||||
|
await seed.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
movePointerAwayFromWorkspaceHoverCard,
|
movePointerAwayFromWorkspaceHoverCard,
|
||||||
openMobileAgentSidebar,
|
openMobileAgentSidebar,
|
||||||
pinWorkspaceFromSidebar,
|
pinWorkspaceFromSidebar,
|
||||||
|
scrollWorkspaceList,
|
||||||
} from "../support/helpers/sidebar";
|
} from "../support/helpers/sidebar";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||||
@@ -582,6 +583,12 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await page.getByTestId("menu-button").click();
|
await page.getByTestId("menu-button").click();
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||||
|
|
||||||
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||||
|
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
||||||
|
await expectWorkspaceHoverCardOpen(page);
|
||||||
|
await scrollWorkspaceList(page);
|
||||||
|
await expectWorkspaceHoverCardClosed(page);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
|||||||
workspaceId: order[8],
|
workspaceId: order[8],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
|
||||||
await expectHiddenWorkspaceShortcuts(page, order);
|
await expectCollapsedWorkspaceShortcuts(page, order);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await project.cleanup();
|
await project.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
|
||||||
await page.getByLabel("Show less", { exact: true }).click();
|
await page.getByLabel("Show less", { exact: true }).click();
|
||||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||||
await expect(rows).toHaveCount(7);
|
await expect(rows).toHaveCount(8);
|
||||||
|
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||||
|
await expect(rows).toHaveCount(7);
|
||||||
await page.keyboard.press("Alt+9");
|
await page.keyboard.press("Alt+9");
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||||
|
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||||
|
await expect(rows).toHaveCount(8);
|
||||||
|
await page.keyboard.press("Alt+]");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||||
|
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
|
||||||
|
await expect(rows).toHaveCount(8);
|
||||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await page.keyboard.press("Alt+]");
|
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
|
||||||
await expect(rows).toHaveCount(7);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,9 @@ async function expectDarkIcons(expected, context) {
|
|||||||
assert.equal(windowState(), expected, context);
|
assert.equal(windowState(), expected, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings > Appearance shows every theme as a tile in one radio group.
|
||||||
function selectTheme(theme) {
|
function selectTheme(theme) {
|
||||||
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
|
|
||||||
assert(current, "Start on Settings > Appearance");
|
|
||||||
ad("press", `label="Theme: ${current}"`, "--settle");
|
|
||||||
ad("press", `label="${theme}"`, "--settle");
|
ad("press", `label="${theme}"`, "--settle");
|
||||||
ad("wait", "text", `Theme: ${theme}`, "5000");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ad("settings", "appearance", "light");
|
ad("settings", "appearance", "light");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||||
import xterm from "@xterm/headless";
|
import xterm from "@xterm/headless";
|
||||||
import { PNG } from "pngjs";
|
import { PNG } from "pngjs";
|
||||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||||
@@ -82,6 +83,8 @@ async function fixtureTerminal(size) {
|
|||||||
rows: size.rows,
|
rows: size.rows,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
});
|
});
|
||||||
|
// The app's buffer uses grapheme widths; the expected cells must too.
|
||||||
|
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||||
await new Promise((done) =>
|
await new Promise((done) =>
|
||||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||||
|
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||||
|
// Leave the daemon running. This test observes the public reconnecting state.
|
||||||
|
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||||
|
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||||
|
assert(
|
||||||
|
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||||
|
"Pass all five arguments",
|
||||||
|
);
|
||||||
|
const scale = Number(coordinateScale);
|
||||||
|
assert(scale > 0, "Pass a positive coordinate scale");
|
||||||
|
mkdirSync(artifacts, { recursive: true });
|
||||||
|
function ad(...args) {
|
||||||
|
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 90_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function capture(name) {
|
||||||
|
const snapshot = ad("snapshot", "--raw", "--json");
|
||||||
|
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||||
|
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||||
|
const nodes = JSON.parse(snapshot).data.nodes;
|
||||||
|
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||||
|
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||||
|
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||||
|
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||||
|
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||||
|
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||||
|
return toast.rect.y / scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||||
|
const closedTop = capture("keyboard-closed");
|
||||||
|
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||||
|
const opened = ad("snapshot", "--raw", "--json");
|
||||||
|
const nodes = JSON.parse(opened).data.nodes;
|
||||||
|
assert(
|
||||||
|
nodes.some(
|
||||||
|
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||||
|
),
|
||||||
|
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||||
|
);
|
||||||
|
const openTop = capture("keyboard-open");
|
||||||
|
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||||
|
console.log(
|
||||||
|
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||||
|
);
|
||||||
@@ -107,6 +107,8 @@ const daemonTest = metroTest.extend<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const test = daemonTest.extend<{
|
const test = daemonTest.extend<{
|
||||||
|
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
|
||||||
|
e2eOnboardingCompleted: boolean;
|
||||||
paseoE2ESetup: void;
|
paseoE2ESetup: void;
|
||||||
outdatedDaemon: OutdatedDaemon;
|
outdatedDaemon: OutdatedDaemon;
|
||||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||||
@@ -114,8 +116,9 @@ const test = daemonTest.extend<{
|
|||||||
projectPickerFixture: TrackedProjectPickerFixture;
|
projectPickerFixture: TrackedProjectPickerFixture;
|
||||||
withWorkspace: WithWorkspace;
|
withWorkspace: WithWorkspace;
|
||||||
}>({
|
}>({
|
||||||
|
e2eOnboardingCompleted: [true, { option: true }],
|
||||||
paseoE2ESetup: [
|
paseoE2ESetup: [
|
||||||
async ({ page }, provide, testInfo) => {
|
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
|
||||||
const daemonPort = getE2EDaemonPort();
|
const daemonPort = getE2EDaemonPort();
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
@@ -157,7 +160,7 @@ const test = daemonTest.extend<{
|
|||||||
const createAgentPreferences = buildCreateAgentPreferences();
|
const createAgentPreferences = buildCreateAgentPreferences();
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
@@ -185,12 +188,20 @@ const test = daemonTest.extend<{
|
|||||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||||
}
|
}
|
||||||
|
// The shared daemon often has no projects, which would open first-run setup over the app.
|
||||||
|
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
|
||||||
|
localStorage.setItem(
|
||||||
|
"@paseo:onboarding",
|
||||||
|
JSON.stringify({ completedAt: new Date(0).toISOString() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
daemon: testDaemon,
|
daemon: testDaemon,
|
||||||
preferences: createAgentPreferences,
|
preferences: createAgentPreferences,
|
||||||
seedNonce,
|
seedNonce,
|
||||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||||
|
onboardingCompleted: e2eOnboardingCompleted,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -113,3 +113,29 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||||
|
// Keep both keys in one task so Escape closes with a selection update still queued.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const key of ["ArrowDown", "Escape"]) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchAddProjectRenderWarnings(page: Page): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
const text = message.text();
|
||||||
|
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
|
||||||
|
warnings.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export async function chooseCustomSkills(page: Page, selected: string[]): Promis
|
|||||||
await all.click();
|
await all.click();
|
||||||
const keep = new Set(selected);
|
const keep = new Set(selected);
|
||||||
for (const name of await listSkillChoices(page)) {
|
for (const name of await listSkillChoices(page)) {
|
||||||
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
|
if (!keep.has(name)) await skillSwitch(page, name).click();
|
||||||
}
|
}
|
||||||
await expectSkillChoices(page, selected);
|
await expectSkillChoices(page, selected);
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ export async function cancelSkillSelection(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSkill(page: Page, name: string): Promise<void> {
|
export async function toggleSkill(page: Page, name: string): Promise<void> {
|
||||||
await page.getByRole("checkbox", { name, exact: true }).click();
|
await skillSwitch(page, name).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function answerNextRemovalWarning(
|
export function answerNextRemovalWarning(
|
||||||
@@ -222,15 +222,21 @@ export async function expectSkillFile(
|
|||||||
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
|
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
|
||||||
const expected = new Set(selected);
|
const expected = new Set(selected);
|
||||||
for (const name of await listSkillChoices(page)) {
|
for (const name of await listSkillChoices(page)) {
|
||||||
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
|
await expect(skillSwitch(page, name)).toBeChecked({
|
||||||
checked: expected.has(name),
|
checked: expected.has(name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bundled skill's switch in the picker; "All skills" sits outside the list. */
|
||||||
|
function skillSwitch(page: Page, name: string) {
|
||||||
|
return page.getByTestId("skill-selection-list-card").getByRole("switch", { name, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function listSkillChoices(page: Page): Promise<string[]> {
|
async function listSkillChoices(page: Page): Promise<string[]> {
|
||||||
return page
|
return page
|
||||||
.getByRole("checkbox")
|
.getByTestId("skill-selection-list-card")
|
||||||
|
.getByRole("switch")
|
||||||
.evaluateAll((elements) =>
|
.evaluateAll((elements) =>
|
||||||
elements
|
elements
|
||||||
.map((element) => element.getAttribute("aria-label") ?? "")
|
.map((element) => element.getAttribute("aria-label") ?? "")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
|
import type { SeededWorkspace } from "./seed-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||||
|
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||||
|
*/
|
||||||
|
export async function startBackgroundAgent(
|
||||||
|
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||||
|
input: { parentId: string; title: string },
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const agent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: input.title,
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "classify the last turn",
|
||||||
|
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||||
|
background: true,
|
||||||
|
});
|
||||||
|
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||||
|
return { id: agent.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||||
|
export async function finishAndArchiveAgent(
|
||||||
|
workspace: Pick<SeededWorkspace, "client">,
|
||||||
|
agentId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||||
|
expect(finished.status).toBe("idle");
|
||||||
|
await workspace.client.archiveAgent(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||||
|
const row = page.getByRole("button", {
|
||||||
|
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||||
|
});
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
|||||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact screens open the meter's details in a sheet. */
|
/** Clicking the meter opens its details in the active layout. */
|
||||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
export async function pressContextWindowMeter(
|
||||||
|
page: Page,
|
||||||
|
name: string | RegExp = METER_NAME,
|
||||||
|
): Promise<Locator> {
|
||||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||||
const sheet = contextWindowDetails(page);
|
const details = contextWindowDetails(page);
|
||||||
await expect(sheet).toBeVisible();
|
await expect(details).toBeVisible();
|
||||||
return sheet;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||||
|
|
||||||
export interface DirectoryBootstrapCounts {
|
export interface DirectoryBootstrapCounts {
|
||||||
agents: number;
|
agents: number;
|
||||||
@@ -294,7 +294,8 @@ function recordClientRequest(
|
|||||||
directoryStarts.total[directory] += 1;
|
directoryStarts.total[directory] += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDaemonWebSocketGate(page: Page) {
|
/** Gates the shared E2E daemon, or the daemon on `port` for a spec that runs its own. */
|
||||||
|
export async function installDaemonWebSocketGate(page: Page, options: { port?: number } = {}) {
|
||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
let reconnectWithFreshClient = false;
|
let reconnectWithFreshClient = false;
|
||||||
let suppressAgentStream = false;
|
let suppressAgentStream = false;
|
||||||
@@ -445,7 +446,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
const routePattern =
|
||||||
|
options.port === undefined
|
||||||
|
? daemonWsRoutePattern()
|
||||||
|
: wsRoutePatternForPort(String(options.port));
|
||||||
|
await page.routeWebSocket(routePattern, (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
blockedConnectionCount += 1;
|
blockedConnectionCount += 1;
|
||||||
for (const resolve of blockedConnectionWaiters) resolve();
|
for (const resolve of blockedConnectionWaiters) resolve();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
|
function listingResponse(message: string | Buffer) {
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(message.toString());
|
||||||
|
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay one real directory response after the daemon has read its contents. */
|
||||||
|
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
|
||||||
|
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
|
||||||
|
let resolveHeld!: () => void;
|
||||||
|
const heldResponse = new Promise<void>((resolve) => {
|
||||||
|
resolveHeld = resolve;
|
||||||
|
});
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||||
|
const server = socket.connectToServer();
|
||||||
|
socket.onMessage((message) => server.send(message));
|
||||||
|
server.onMessage((message) => {
|
||||||
|
const response = listingResponse(message);
|
||||||
|
if (!held && response?.mode === "list" && response.path === directoryPath) {
|
||||||
|
held = { socket, message, requestId: response.requestId };
|
||||||
|
resolveHeld();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
waitUntilHeld: () => heldResponse,
|
||||||
|
release: async () => {
|
||||||
|
if (!held) throw new Error("Directory response must be held before release");
|
||||||
|
held.socket.send(held.message);
|
||||||
|
// Let the delivered reply update the UI before the user reopens the folder.
|
||||||
|
await page.evaluate(
|
||||||
|
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
|||||||
);
|
);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ registryKey, nonceKey, disableSeedKey }) => {
|
||||||
|
const nonce = localStorage.getItem(nonceKey);
|
||||||
|
if (!nonce) throw new Error("Expected the e2e seed nonce");
|
||||||
|
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
|
||||||
|
for (const host of hosts) {
|
||||||
|
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
|
||||||
|
host.preferredConnectionId = "offline";
|
||||||
|
}
|
||||||
|
localStorage.setItem(registryKey, JSON.stringify(hosts));
|
||||||
|
localStorage.setItem(disableSeedKey, nonce);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
registryKey: REGISTRY_KEY,
|
||||||
|
nonceKey: SEED_NONCE_KEY,
|
||||||
|
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface IsolatedHostDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IsolatedHostDaemonOptions {
|
export interface IsolatedHostDaemonOptions {
|
||||||
|
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
|
||||||
|
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
|
||||||
environment?: NodeJS.ProcessEnv;
|
environment?: NodeJS.ProcessEnv;
|
||||||
mutableRelay?: {
|
mutableRelay?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -93,6 +95,28 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withMutableRelay(
|
||||||
|
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
|
||||||
|
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
|
||||||
|
): NonNullable<IsolatedHostDaemonOptions["config"]> {
|
||||||
|
const endpoint =
|
||||||
|
relay.endpoint ??
|
||||||
|
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
daemon: {
|
||||||
|
...config.daemon,
|
||||||
|
relay: {
|
||||||
|
enabled: relay.enabled,
|
||||||
|
endpoint,
|
||||||
|
publicEndpoint: endpoint,
|
||||||
|
useTls: false,
|
||||||
|
publicUseTls: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function startIsolatedHostDaemon(
|
export async function startIsolatedHostDaemon(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
options: IsolatedHostDaemonOptions = {},
|
options: IsolatedHostDaemonOptions = {},
|
||||||
@@ -138,24 +162,13 @@ export async function startIsolatedHostDaemon(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.mutableRelay) {
|
const config = options.mutableRelay
|
||||||
const endpoint =
|
? withMutableRelay(options.config ?? {}, options.mutableRelay)
|
||||||
options.mutableRelay.endpoint ??
|
: options.config;
|
||||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
if (config) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(paseoHome, "config.json"),
|
path.join(paseoHome, "config.json"),
|
||||||
`${JSON.stringify({
|
`${JSON.stringify({ version: 1, ...config })}\n`,
|
||||||
version: 1,
|
|
||||||
daemon: {
|
|
||||||
relay: {
|
|
||||||
enabled: options.mutableRelay.enabled,
|
|
||||||
endpoint,
|
|
||||||
publicEndpoint: endpoint,
|
|
||||||
useTls: false,
|
|
||||||
publicUseTls: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})}\n`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const serverDir = publishedPackageRoot
|
const serverDir = publishedPackageRoot
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
|
||||||
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
|
||||||
|
|
||||||
|
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
|
||||||
|
export async function openAppAsNewUser(
|
||||||
|
page: Page,
|
||||||
|
host: { serverId: string; port: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const seededHost = buildSeededHost({
|
||||||
|
serverId: host.serverId,
|
||||||
|
endpoint: `127.0.0.1:${host.port}`,
|
||||||
|
nowIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await page.route(/:6767\b/, (route) => route.abort());
|
||||||
|
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
|
||||||
|
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
|
||||||
|
});
|
||||||
|
await page.addInitScript(
|
||||||
|
({ registryHost, preferences }) => {
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost]));
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
},
|
||||||
|
{ registryHost: seededHost, preferences: buildCreateAgentPreferences() },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupModal(page: Page): Locator {
|
||||||
|
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickTheme(page: Page, name: string): Promise<void> {
|
||||||
|
const tile = setupModal(page).getByRole("radio", { name, exact: true });
|
||||||
|
await tile.click();
|
||||||
|
await expect(tile).toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function continueSetup(page: Page): Promise<void> {
|
||||||
|
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipSetup(page: Page): Promise<void> {
|
||||||
|
await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click();
|
||||||
|
await expect(setupModal(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */
|
||||||
|
export async function addProjectFromSetup(page: Page, projectPath: string): Promise<void> {
|
||||||
|
await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click();
|
||||||
|
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await chooseAddProjectMethod(page, "directory-search");
|
||||||
|
await addProjectFlowInput(page).fill(projectPath);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showSetupAgainFromSettings(page: Page): Promise<void> {
|
||||||
|
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||||
|
await page.getByRole("button", { name: "Show setup again", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToSetupStep(page: Page, label: string): Promise<void> {
|
||||||
|
await setupModal(page).getByRole("tab", { name: label, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paseoToolsSwitch(scope: Page | Locator): Locator {
|
||||||
|
return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the host gives its agents Paseo tools, read from the host itself. */
|
||||||
|
export async function hostGivesAgentsPaseoTools(host: { port: number }): Promise<boolean> {
|
||||||
|
const client = await connectDaemonClient<
|
||||||
|
Pick<InternalDaemonClient, "connect" | "close" | "getDaemonConfig">
|
||||||
|
>({ clientIdPrefix: "app-e2e-onboarding", port: host.port });
|
||||||
|
try {
|
||||||
|
return (await client.getDaemonConfig()).config.mcp.injectIntoAgents !== false;
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishSetup(page: Page): Promise<void> {
|
||||||
|
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
|
||||||
|
await expect(setupModal(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the Phone step's Relay row: the pair device modal, over setup. */
|
||||||
|
export async function openRelayPairingFromSetup(page: Page): Promise<Locator> {
|
||||||
|
await setupModal(page).getByRole("button", { name: "Relay", exact: true }).click();
|
||||||
|
const pairing = page.getByTestId("onboarding-pair-device-card");
|
||||||
|
await expect(pairing).toBeVisible();
|
||||||
|
return pairing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeRelayPairing(pairing: Locator): Promise<void> {
|
||||||
|
await pairing.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
await expect(pairing).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicks the Phone step's Tailscale row and returns the docs page it opened. */
|
||||||
|
export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
|
||||||
|
await page
|
||||||
|
.context()
|
||||||
|
.route("https://paseo.sh/**", (route) =>
|
||||||
|
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
|
||||||
|
);
|
||||||
|
const popup = page.waitForEvent("popup");
|
||||||
|
await setupModal(page).getByRole("link", { name: "Tailscale, Docs", exact: true }).click();
|
||||||
|
const docs = await popup;
|
||||||
|
await docs.waitForLoadState("domcontentloaded");
|
||||||
|
const url = docs.url();
|
||||||
|
await docs.close();
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupHost {
|
||||||
|
serverId: string;
|
||||||
|
port: number;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
|
||||||
|
* never depend on the machine running the test. Its HOME is a scratch folder, so the skills setup
|
||||||
|
* installs never land in the real ~/.agents, ~/.claude or ~/.codex. `withBrokenProvider` adds a
|
||||||
|
* provider that cannot start.
|
||||||
|
*/
|
||||||
|
export async function startSetupHost(
|
||||||
|
label: string,
|
||||||
|
options: {
|
||||||
|
claudeSessionsIn?: readonly string[];
|
||||||
|
withBrokenProvider?: boolean;
|
||||||
|
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
|
||||||
|
} = {},
|
||||||
|
): Promise<SetupHost> {
|
||||||
|
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||||
|
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-home-"));
|
||||||
|
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||||
|
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
|
||||||
|
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
|
||||||
|
mutableRelay: options.mutableRelay,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
port: daemon.port,
|
||||||
|
close: async () => {
|
||||||
|
await daemon.close();
|
||||||
|
await rm(claudeConfigDir, { recursive: true, force: true });
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
|
||||||
|
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
|
||||||
|
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
|
||||||
|
const newest = Date.now() - 60_000;
|
||||||
|
for (const [index, cwd] of cwds.entries()) {
|
||||||
|
const sessionId = `onboarding-session-${index + 1}`;
|
||||||
|
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
||||||
|
await mkdir(projectDir, { recursive: true });
|
||||||
|
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
const record = {
|
||||||
|
type: "user",
|
||||||
|
uuid: `${sessionId}-user`,
|
||||||
|
message: { role: "user", content: `Work on session ${index + 1}` },
|
||||||
|
cwd,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
|
||||||
|
const timestamp = new Date(newest - index * 60_000);
|
||||||
|
await utimes(sessionPath, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
|
||||||
|
const brokenProvider = {
|
||||||
|
"broken-acp": {
|
||||||
|
extends: "acp",
|
||||||
|
label: "Broken ACP",
|
||||||
|
command: ["missing-agent-command", "acp"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
providers: {
|
||||||
|
codex: { enabled: false },
|
||||||
|
copilot: { enabled: false },
|
||||||
|
omp: { enabled: false },
|
||||||
|
opencode: { enabled: false },
|
||||||
|
pi: { enabled: false },
|
||||||
|
...(options.withBrokenProvider ? brokenProvider : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The switch that picks a suggested project for adding. */
|
||||||
|
export function projectSuggestion(page: Page, name: string): Locator {
|
||||||
|
return setupModal(page).getByRole("switch", { name, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole suggestion row, with its path, last use and any add error beside the switch. */
|
||||||
|
export function projectSuggestionRow(page: Page, name: string): Locator {
|
||||||
|
return setupModal(page)
|
||||||
|
.getByTestId("project-suggestion")
|
||||||
|
.filter({ has: page.getByRole("switch", { name, exact: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The footer's main button, which adds the checked suggestions when there are any. */
|
||||||
|
export function setupPrimaryButton(page: Page, label: string): Locator {
|
||||||
|
return setupModal(page).getByRole("button", { name: label, exact: true });
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
|||||||
import type { Locator, TestInfo } from "@playwright/test";
|
import type { Locator, TestInfo } from "@playwright/test";
|
||||||
import { test, expect, type Page } from "@playwright/test";
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
import { gotoAppShell } from "./app";
|
import { gotoAppShell } from "./app";
|
||||||
|
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||||
|
import { expectTurnCopyButton } from "./agent-stream";
|
||||||
|
import { submitMessage } from "./composer";
|
||||||
import { buildAgentRoute } from "./mock-agent";
|
import { buildAgentRoute } from "./mock-agent";
|
||||||
import { openCommandCenter } from "./command-center";
|
import { openCommandCenter } from "./command-center";
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +91,7 @@ export default function contribute(client) {
|
|||||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||||
command("finish", "Finish deployment", () => finish());
|
command("finish", "Finish deployment", () => finish());
|
||||||
command("finish-review", "Finish review", () => finishReview());
|
command("finish-review", "Finish review", () => finishReview());
|
||||||
@@ -102,10 +105,16 @@ export default function contribute(client) {
|
|||||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
|
let pillsHidden = false;
|
||||||
const applyWorkspace = (workspace) => {
|
const applyWorkspace = (workspace) => {
|
||||||
if (workspace.id !== workspaceId) return;
|
if (workspace.id !== workspaceId) return;
|
||||||
|
const hidePills = workspace.name === "No composer pills";
|
||||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||||
|
if (hidePills === pillsHidden) return;
|
||||||
|
pillsHidden = hidePills;
|
||||||
|
review.update({ visible: !hidePills });
|
||||||
|
usage.update({ visible: !hidePills });
|
||||||
};
|
};
|
||||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||||
subscription.subscribe({
|
subscription.subscribe({
|
||||||
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
|||||||
await page.emulateMedia({ colorScheme: "light" });
|
await page.emulateMedia({ colorScheme: "light" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||||
|
|
||||||
|
function chevronOf(page: Page, title: string): Locator {
|
||||||
|
return page
|
||||||
|
.getByRole("button", { name: title, exact: true })
|
||||||
|
.getByTestId("plugin-button-chevron");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||||
|
async function expectComposerPillChevrons(page: Page) {
|
||||||
|
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||||
|
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||||
|
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setComposerPillsShown(
|
||||||
|
page: Page,
|
||||||
|
showcase: { setTitle(title: string): Promise<unknown> },
|
||||||
|
shown: boolean,
|
||||||
|
) {
|
||||||
|
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||||
|
for (const title of COMPOSER_PILLS) {
|
||||||
|
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lastTurnTop(page: Page): Promise<number> {
|
||||||
|
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
return box!.y;
|
||||||
|
}
|
||||||
|
|
||||||
async function expectHeaderTextTruncated(page: Page) {
|
async function expectHeaderTextTruncated(page: Page) {
|
||||||
const title = page.getByTestId("workspace-header-title");
|
const title = page.getByTestId("workspace-header-title");
|
||||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||||
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
|
|||||||
run: (buttons: {
|
run: (buttons: {
|
||||||
openWideMenusAndPopovers(): Promise<void>;
|
openWideMenusAndPopovers(): Promise<void>;
|
||||||
runAndUpdateActions(): Promise<void>;
|
runAndUpdateActions(): Promise<void>;
|
||||||
|
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||||
openCompactSheets(): Promise<void>;
|
openCompactSheets(): Promise<void>;
|
||||||
useCompactWorkspaceMenu(): Promise<void>;
|
useCompactWorkspaceMenu(): Promise<void>;
|
||||||
hideAndUnloadOpenButtons(): Promise<void>;
|
hideAndUnloadOpenButtons(): Promise<void>;
|
||||||
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
|
|||||||
.getByRole("button", { name: "Header checks", exact: true })
|
.getByRole("button", { name: "Header checks", exact: true })
|
||||||
.getByTestId("plugin-button-chevron"),
|
.getByTestId("plugin-button-chevron"),
|
||||||
).toHaveCount(1);
|
).toHaveCount(1);
|
||||||
await expect(
|
await expectComposerPillChevrons(page);
|
||||||
page
|
|
||||||
.getByRole("button", { name: "Composer checks", exact: true })
|
|
||||||
.getByTestId("plugin-button-chevron"),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expectSurfaceVisible(page);
|
await expectSurfaceVisible(page);
|
||||||
await captureSchemes(
|
await captureSchemes(
|
||||||
page,
|
page,
|
||||||
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
|
|||||||
"Review ready",
|
"Review ready",
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
keepTrackSpaceWithoutPills: () =>
|
||||||
|
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||||
|
await page.setViewportSize(WIDE);
|
||||||
|
await submitMessage(page, "Stream a turn under the composer pills");
|
||||||
|
await expectTurnCopyButton(page);
|
||||||
|
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||||
|
for (const viewport of [WIDE, COMPACT]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await scrollAgentChatToBottom(page);
|
||||||
|
const top = await lastTurnTop(page);
|
||||||
|
await setComposerPillsShown(page, showcase, false);
|
||||||
|
expect(await lastTurnTop(page)).toBe(top);
|
||||||
|
await setComposerPillsShown(page, showcase, true);
|
||||||
|
expect(await lastTurnTop(page)).toBe(top);
|
||||||
|
}
|
||||||
|
await page.setViewportSize(WIDE);
|
||||||
|
}),
|
||||||
openCompactSheets: () =>
|
openCompactSheets: () =>
|
||||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||||
await page.setViewportSize(COMPACT);
|
await page.setViewportSize(COMPACT);
|
||||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
await expect(
|
||||||
|
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expectComposerPillChevrons(page);
|
||||||
|
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||||
).toContainText("Checks");
|
).toContainText("Checks");
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { copyPluginExample } from "./plugin-fixture";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||||
import { expect, test as base, type Page } from "../fixtures";
|
import { expect, test as base, type Page } from "../fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||||
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
|||||||
|
|
||||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
|
|
||||||
|
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||||
|
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||||
|
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||||
|
const TABBED_SOURCE = `import React from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||||
|
|
||||||
|
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||||
|
|
||||||
|
function General({ theme }) {
|
||||||
|
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Advanced({ theme }) {
|
||||||
|
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||||
|
function TranslatedIcon({ color, size }) {
|
||||||
|
const { t } = useTranslation(messages);
|
||||||
|
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrokenIcon() {
|
||||||
|
throw new Error("Settings tab icon exploded");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function contribute(client) {
|
||||||
|
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||||
|
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function writeTabbedPlugin() {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "paseo-plugin.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: TABBED_PLUGIN_ID,
|
||||||
|
name: TABBED_PLUGIN_NAME,
|
||||||
|
requirements: pluginRequirements,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||||
|
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||||
settingsPlugin: [
|
settingsPlugin: [
|
||||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||||
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
|||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
const previous = await client.getDaemonConfig();
|
const previous = await client.getDaemonConfig();
|
||||||
const example = await copyPluginExample("settings");
|
const example = await copyPluginExample("settings");
|
||||||
|
const tabbed = await writeTabbedPlugin();
|
||||||
try {
|
try {
|
||||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
await client.installDirectoryPlugin(example.directory);
|
await client.installDirectoryPlugin(example.directory);
|
||||||
|
await client.installDirectoryPlugin(tabbed.directory);
|
||||||
await provide(client);
|
await provide(client);
|
||||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||||
} finally {
|
} finally {
|
||||||
await client.removePlugin("settings-example");
|
await client.removePlugin("settings-example");
|
||||||
|
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||||
await client.close();
|
await client.close();
|
||||||
await example.cleanup();
|
await example.cleanup();
|
||||||
|
await tabbed.cleanup();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
|
|||||||
}
|
}
|
||||||
async function openPluginScreen(page: Page) {
|
async function openPluginScreen(page: Page) {
|
||||||
await openHostSection(page, getServerId(), "plugins");
|
await openHostSection(page, getServerId(), "plugins");
|
||||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
await openSettingsFromCog(page);
|
||||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
}
|
||||||
|
/** 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();
|
||||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
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");
|
||||||
|
}
|
||||||
|
export async function openPluginsPage(page: Page) {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openHostSection(page, getServerId(), "plugins");
|
||||||
|
}
|
||||||
|
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Display", 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();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||||
|
export async function expectNamedPluginRow(page: Page) {
|
||||||
|
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
export async function openTabbedSettings(page: Page) {
|
||||||
|
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
function settingsTab(page: Page, title: string) {
|
||||||
|
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||||
|
}
|
||||||
|
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||||
|
await settingsTab(page, title).click();
|
||||||
|
await expectSettingsTab(page, title);
|
||||||
|
}
|
||||||
|
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()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.goto(
|
||||||
|
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
await expectSettingsTab(page, title);
|
||||||
}
|
}
|
||||||
export async function groupByWorkspace(page: Page) {
|
export async function groupByWorkspace(page: Page) {
|
||||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||||
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
|
|||||||
}
|
}
|
||||||
export async function returnAndReopenSettings(page: Page) {
|
export async function returnAndReopenSettings(page: Page) {
|
||||||
await goBackInSettings(page);
|
await goBackInSettings(page);
|
||||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
await openSettingsFromCog(page);
|
||||||
await expect(actions).toBeVisible();
|
|
||||||
await actions.click();
|
|
||||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
|
||||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
|
||||||
}
|
}
|
||||||
export async function returnToPluginsPage(page: Page) {
|
export async function returnToPluginsPage(page: Page) {
|
||||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||||
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
|
|||||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||||
).toBeVisible();
|
).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);
|
||||||
|
}
|
||||||
export async function expectSettingsUnavailable(page: Page) {
|
export async function expectSettingsUnavailable(page: Page) {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
|||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
background?: boolean;
|
||||||
}): Promise<{ id: string; status: string }>;
|
}): Promise<{ id: string; status: string }>;
|
||||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||||
entries: Array<{
|
entries: Array<{
|
||||||
|
|||||||
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
|
||||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
|
||||||
|
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
|
||||||
|
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDirectHostSsl(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
|
||||||
|
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ export async function focusWorkspaceRowWithKeyboard(
|
|||||||
await expect(row).toBeFocused();
|
await expect(row).toBeFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||||
|
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||||
|
const before = await list.evaluate((element) => element.scrollTop);
|
||||||
|
await list.evaluate((element) =>
|
||||||
|
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
|
||||||
|
);
|
||||||
|
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
apply plugin: 'expo-module-gradle-plugin'
|
||||||
|
|
||||||
|
group = 'sh.paseo.keyboard'
|
||||||
|
version = '0.1.0'
|
||||||
|
android {
|
||||||
|
namespace 'sh.paseo.keyboard'
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName '0.1.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
implementation 'com.facebook.react:react-android'
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
|
||||||
|
import android.view.KeyCharacterMap
|
||||||
|
|
||||||
|
internal fun hardwareCharacterKey(character: Int): String =
|
||||||
|
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
|
||||||
|
else String(Character.toChars(character))
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
|
||||||
|
import android.content.ComponentCallbacks
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.input.InputManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.Window
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.view.inputmethod.BaseInputConnection
|
||||||
|
import com.facebook.react.bridge.UiThreadUtil
|
||||||
|
import com.facebook.react.bridge.ReactContext
|
||||||
|
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
internal object HardwareKeyboard {
|
||||||
|
var module: PaseoHardwareKeyboardModule? = null
|
||||||
|
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||||
|
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||||
|
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||||
|
private val consumedPresses = mutableSetOf<Int>()
|
||||||
|
|
||||||
|
fun observe(window: Window) {
|
||||||
|
if (windows.containsKey(window)) return
|
||||||
|
val callback = KeyboardWindowCallback(window, window.callback)
|
||||||
|
windows[window] = callback
|
||||||
|
window.callback = callback
|
||||||
|
}
|
||||||
|
fun release(window: Window) {
|
||||||
|
val callback = windows.remove(window) ?: return
|
||||||
|
if (window.callback === callback) window.callback = callback.previous
|
||||||
|
}
|
||||||
|
fun releaseAll() {
|
||||||
|
windows.keys.toList().forEach { release(it) }
|
||||||
|
consumedPresses.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||||
|
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||||
|
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||||
|
val code = code(event.keyCode)
|
||||||
|
if (code.isEmpty()) return false
|
||||||
|
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
|
||||||
|
val character = event.getUnicodeChar(textModifiers)
|
||||||
|
val named = !code.startsWith("Key") && !code.startsWith("Digit")
|
||||||
|
val key = when {
|
||||||
|
code.startsWith("Control") -> "Control"
|
||||||
|
code.startsWith("Meta") -> "Meta"
|
||||||
|
code.startsWith("Shift") -> "Shift"
|
||||||
|
code.startsWith("Alt") -> "Alt"
|
||||||
|
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
|
||||||
|
character != 0 -> hardwareCharacterKey(character)
|
||||||
|
else -> code
|
||||||
|
}
|
||||||
|
module?.emit(mapOf(
|
||||||
|
"key" to key, "code" to code,
|
||||||
|
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
|
||||||
|
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
|
||||||
|
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
|
||||||
|
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
|
||||||
|
))
|
||||||
|
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||||
|
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||||
|
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||||
|
return overlayOwnsKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun code(keyCode: Int): String = when (keyCode) {
|
||||||
|
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
|
||||||
|
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
|
||||||
|
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
|
||||||
|
KeyEvent.KEYCODE_ESCAPE -> "Escape"
|
||||||
|
KeyEvent.KEYCODE_DEL -> "Backspace"
|
||||||
|
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
|
||||||
|
KeyEvent.KEYCODE_TAB -> "Tab"
|
||||||
|
KeyEvent.KEYCODE_SPACE -> "Space"
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
|
||||||
|
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
|
||||||
|
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
|
||||||
|
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
|
||||||
|
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
|
||||||
|
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
|
||||||
|
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
|
||||||
|
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
|
||||||
|
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
|
||||||
|
KeyEvent.KEYCODE_COMMA -> "Comma"
|
||||||
|
KeyEvent.KEYCODE_PERIOD -> "Period"
|
||||||
|
KeyEvent.KEYCODE_MINUS -> "Minus"
|
||||||
|
KeyEvent.KEYCODE_EQUALS -> "Equal"
|
||||||
|
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
|
||||||
|
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
|
||||||
|
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
|
||||||
|
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
|
||||||
|
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
|
||||||
|
KeyEvent.KEYCODE_GRAVE -> "Backquote"
|
||||||
|
KeyEvent.KEYCODE_SLASH -> "Slash"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
val editable = (window.currentFocus as? EditText)?.text
|
||||||
|
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||||
|
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
|
||||||
|
private var inputManager: InputManager? = null
|
||||||
|
private var registeredReactContext: ReactContext? = null
|
||||||
|
private fun keyboardPresent(): Boolean {
|
||||||
|
val config = registeredReactContext?.resources?.configuration
|
||||||
|
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
|
||||||
|
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
|
||||||
|
}
|
||||||
|
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
|
||||||
|
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
|
||||||
|
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
|
||||||
|
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
|
||||||
|
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
|
||||||
|
override fun onLowMemory() {}
|
||||||
|
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
|
||||||
|
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("PaseoHardwareKeyboard")
|
||||||
|
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
|
||||||
|
OnCreate {
|
||||||
|
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
|
||||||
|
val context = appContext.reactContext
|
||||||
|
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
|
||||||
|
registeredReactContext = context
|
||||||
|
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||||
|
inputManager = context.getSystemService(InputManager::class.java)
|
||||||
|
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
|
||||||
|
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||||
|
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
|
||||||
|
}
|
||||||
|
OnDestroy {
|
||||||
|
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
|
||||||
|
inputManager = null
|
||||||
|
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||||
|
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||||
|
registeredReactContext = null
|
||||||
|
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
|
||||||
|
HardwareKeyboard.module = null
|
||||||
|
HardwareKeyboard.overlayKeys = emptySet()
|
||||||
|
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Function("getHardwareKeyboardPresent") { keyboardPresent() }
|
||||||
|
Function("setOverlayKeys") { keys: List<String> ->
|
||||||
|
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import expo.modules.core.interfaces.Package
|
||||||
|
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||||
|
|
||||||
|
class PaseoHardwareKeyboardPackage : Package {
|
||||||
|
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
|
||||||
|
object : ReactActivityLifecycleListener {
|
||||||
|
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
|
||||||
|
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
|
||||||
|
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
|
||||||
|
// A modal can forward the same physical event into the activity window. Keep
|
||||||
|
// observation outside that nested delivery, without retaining recycled KeyEvents.
|
||||||
|
internal class WindowKeyDispatcher<Event : Any> {
|
||||||
|
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
|
||||||
|
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
|
||||||
|
if (!delivering.add(event)) return deliver()
|
||||||
|
return try { observe() || deliver() } finally { delivering.remove(event) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
class HardwareCharacterKeyTest {
|
||||||
|
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
|
||||||
|
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
|
||||||
|
}
|
||||||
|
@Test fun ordinaryUnicodeRemainsText() {
|
||||||
|
assertEquals("é", hardwareCharacterKey(0xE9))
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package sh.paseo.keyboard
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class WindowKeyDispatcherTest {
|
||||||
|
@Test fun observesOnePressAcrossNestedWindows() {
|
||||||
|
val dispatcher = WindowKeyDispatcher<Any>()
|
||||||
|
val event = Any()
|
||||||
|
var observations = 0
|
||||||
|
var deliveries = 0
|
||||||
|
dispatcher.dispatch(event, { observations++; false }, {
|
||||||
|
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
|
||||||
|
})
|
||||||
|
assertEquals(1, observations)
|
||||||
|
assertEquals(1, deliveries)
|
||||||
|
dispatcher.dispatch(event, { observations++; false }, { false })
|
||||||
|
assertEquals(2, observations)
|
||||||
|
}
|
||||||
|
@Test fun consumedOverlayKeyDoesNotReachTheView() {
|
||||||
|
val dispatcher = WindowKeyDispatcher<Any>()
|
||||||
|
var deliveries = 0
|
||||||
|
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
|
||||||
|
assertEquals(0, deliveries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const { withAppDelegate } = require("expo/config-plugins");
|
||||||
|
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
|
||||||
|
|
||||||
|
function configureHardwareKeyboardAppDelegate(source) {
|
||||||
|
source = source.replace(
|
||||||
|
/^import PaseoHardwareKeyboard$/m,
|
||||||
|
"internal import PaseoHardwareKeyboard",
|
||||||
|
);
|
||||||
|
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
|
||||||
|
if (!source.includes(SCENE_START)) {
|
||||||
|
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
|
||||||
|
}
|
||||||
|
const installation = `${SCENE_START}
|
||||||
|
if let window {
|
||||||
|
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
|
||||||
|
self.window = observedWindow
|
||||||
|
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
|
||||||
|
}`;
|
||||||
|
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
|
||||||
|
}
|
||||||
|
module.exports = function withHardwareKeyboardWindow(config) {
|
||||||
|
return withAppDelegate(config, (modConfig) => {
|
||||||
|
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
|
||||||
|
modConfig.modResults.contents,
|
||||||
|
);
|
||||||
|
return modConfig;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"platforms": ["ios"],
|
"platforms": ["ios", "android"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["PaseoHardwareKeyboardModule"],
|
"modules": ["PaseoHardwareKeyboardModule"]
|
||||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-11
@@ -1,27 +1,53 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GameController
|
||||||
|
|
||||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||||
|
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||||
|
|
||||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||||
private var isHardwareSubmitEnabled = false
|
private var isHardwareSubmitEnabled = false
|
||||||
|
private var overlayKeys: Set<String> = []
|
||||||
|
|
||||||
@objc
|
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||||
public override func createRootViewController() -> UIViewController? {
|
/// here, so the constants stay in the one place that owns UIKit vocabulary.
|
||||||
return PaseoHardwareKeyboardRootViewController()
|
private let namedKeyInputs: [String: String] = [
|
||||||
|
"Escape": UIKeyCommand.inputEscape,
|
||||||
|
"ArrowUp": UIKeyCommand.inputUpArrow,
|
||||||
|
"ArrowDown": UIKeyCommand.inputDownArrow,
|
||||||
|
"Enter": "\r",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Whether the first responder is mid-IME-composition.
|
||||||
|
///
|
||||||
|
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||||
|
/// chosen: Enter confirms the candidate, and consuming it as a key command
|
||||||
|
/// would submit the half-composed text instead.
|
||||||
|
private func isComposingText() -> Bool {
|
||||||
|
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return responder.markedTextRange != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PaseoHardwareKeyboardModule: Module {
|
public class PaseoHardwareKeyboardModule: Module {
|
||||||
|
private var keyboardObservers: [NSObjectProtocol] = []
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("PaseoHardwareKeyboard")
|
Name("PaseoHardwareKeyboard")
|
||||||
|
|
||||||
Events(hardwareSubmitEventName)
|
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
|
||||||
|
|
||||||
OnCreate {
|
OnCreate {
|
||||||
activeModule = self
|
activeModule = self
|
||||||
|
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
|
||||||
|
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
|
||||||
|
|
||||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -29,25 +55,105 @@ public class PaseoHardwareKeyboardModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Function("setOverlayKeys") { (keys: [String]) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
|
||||||
|
UIMenuSystem.main.setNeedsRebuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OnDestroy {
|
OnDestroy {
|
||||||
|
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||||
|
self.keyboardObservers = []
|
||||||
if activeModule === self {
|
if activeModule === self {
|
||||||
activeModule = nil
|
activeModule = nil
|
||||||
}
|
|
||||||
isHardwareSubmitEnabled = false
|
isHardwareSubmitEnabled = false
|
||||||
|
overlayKeys = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func emitHardwareKeyboardSubmit() {
|
fileprivate func emitHardwareKeyboardSubmit() {
|
||||||
sendEvent(hardwareSubmitEventName, [:])
|
sendEvent(hardwareSubmitEventName, [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
|
||||||
|
guard let key = press.key else { return }
|
||||||
|
let code = hardwareKeyCode(key)
|
||||||
|
let characters = key.characters
|
||||||
|
let named = namedKeyInputs.first { $0.value == characters }?.key
|
||||||
|
let keyName: String
|
||||||
|
if code.hasPrefix("Meta") { keyName = "Meta" }
|
||||||
|
else if code.hasPrefix("Control") { keyName = "Control" }
|
||||||
|
else if code.hasPrefix("Shift") { keyName = "Shift" }
|
||||||
|
else if code.hasPrefix("Alt") { keyName = "Alt" }
|
||||||
|
else { keyName = named ?? (characters.isEmpty ? code : characters) }
|
||||||
|
guard !code.isEmpty else { return }
|
||||||
|
sendEvent(hardwareShortcutEventName, [
|
||||||
|
"key": keyName,
|
||||||
|
"code": code,
|
||||||
|
"altKey": key.modifierFlags.contains(.alternate),
|
||||||
|
"ctrlKey": key.modifierFlags.contains(.control),
|
||||||
|
"metaKey": key.modifierFlags.contains(.command),
|
||||||
|
"shiftKey": key.modifierFlags.contains(.shift),
|
||||||
|
"repeat": isRepeat,
|
||||||
|
"isComposing": isComposingText(),
|
||||||
|
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||||
override var keyCommands: [UIKeyCommand]? {
|
private var pressedCodes: Set<Int> = []
|
||||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
public static func observing(_ original: UIWindow) -> UIWindow {
|
||||||
return super.keyCommands
|
if original is PaseoHardwareKeyboardWindow { return original }
|
||||||
|
guard let scene = original.windowScene else { return original }
|
||||||
|
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
|
||||||
|
observed.windowLevel = original.windowLevel
|
||||||
|
observed.backgroundColor = original.backgroundColor
|
||||||
|
let controller = original.rootViewController
|
||||||
|
original.rootViewController = nil
|
||||||
|
original.isHidden = true
|
||||||
|
observed.rootViewController = controller
|
||||||
|
observed.makeKeyAndVisible()
|
||||||
|
return observed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override func sendEvent(_ event: UIEvent) {
|
||||||
|
if let event = event as? UIPressesEvent {
|
||||||
|
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
|
||||||
|
guard let key = press.key else { continue }
|
||||||
|
let raw = Int(key.keyCode.rawValue)
|
||||||
|
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
|
||||||
|
if press.phase == .began { pressedCodes.insert(raw) }
|
||||||
|
else { pressedCodes.remove(raw) }
|
||||||
|
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.sendEvent(event)
|
||||||
|
}
|
||||||
|
public override var keyCommands: [UIKeyCommand]? {
|
||||||
|
var commands = super.keyCommands ?? []
|
||||||
|
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
|
||||||
|
&& UIDevice.current.userInterfaceIdiom == .pad
|
||||||
|
{
|
||||||
|
commands.append(makeSubmitCommand())
|
||||||
|
}
|
||||||
|
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether something registered has already asked for an unmodified Return.
|
||||||
|
///
|
||||||
|
/// The submit command is registered for as long as the composer holds focus,
|
||||||
|
/// which includes while a picker is open over it. Registering both would put
|
||||||
|
/// two commands on one press, and the composer would win a press the picker
|
||||||
|
/// asked for — Enter would create the workspace instead of choosing a project.
|
||||||
|
private var isPlainReturnClaimed: Bool {
|
||||||
|
overlayKeys.contains("Enter")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSubmitCommand() -> UIKeyCommand {
|
||||||
let command = UIKeyCommand(
|
let command = UIKeyCommand(
|
||||||
input: "\r",
|
input: "\r",
|
||||||
modifierFlags: [],
|
modifierFlags: [],
|
||||||
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
|||||||
if #available(iOS 15.0, *) {
|
if #available(iOS 15.0, *) {
|
||||||
command.wantsPriorityOverSystemBehavior = true
|
command.wantsPriorityOverSystemBehavior = true
|
||||||
}
|
}
|
||||||
return (super.keyCommands ?? []) + [command]
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
|
||||||
|
guard let input = namedKeyInputs[key] else { return nil }
|
||||||
|
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
|
||||||
|
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
|||||||
activeModule?.emitHardwareKeyboardSubmit()
|
activeModule?.emitHardwareKeyboardSubmit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
|
||||||
|
|
||||||
private func canSubmitCurrentTextInput() -> Bool {
|
private func canSubmitCurrentTextInput() -> Bool {
|
||||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||||
return false
|
return false
|
||||||
@@ -97,3 +213,25 @@ private extension UIResponder {
|
|||||||
UIResponder.currentFirstResponder = self
|
UIResponder.currentFirstResponder = self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func hardwareKeyCode(_ key: UIKey) -> String {
|
||||||
|
let raw = key.keyCode.rawValue
|
||||||
|
if raw >= 4 && raw <= 29 {
|
||||||
|
let letter = UnicodeScalar(Int(raw) + 61)!
|
||||||
|
return "Key" + String(letter)
|
||||||
|
}
|
||||||
|
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
|
||||||
|
if raw == 39 { return "Digit0" }
|
||||||
|
let codes: [Int: String] = [
|
||||||
|
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
|
||||||
|
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
|
||||||
|
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
|
||||||
|
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
|
||||||
|
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
|
||||||
|
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
|
||||||
|
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
|
||||||
|
]
|
||||||
|
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
|
||||||
|
return codes[Int(raw)] ?? ""
|
||||||
|
}
|
||||||
|
|||||||
+60
-2
@@ -4,11 +4,13 @@ import android.graphics.Rect
|
|||||||
import android.text.Selection
|
import android.text.Selection
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.os.SystemClock
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.facebook.react.bridge.BridgeReactContext
|
|
||||||
import com.facebook.react.bridge.JavaOnlyArray
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
import com.facebook.react.bridge.JavaOnlyMap
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
@@ -24,6 +26,40 @@ import org.junit.runner.RunWith
|
|||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ScrollSelectionTest {
|
class ScrollSelectionTest {
|
||||||
|
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
|
||||||
|
val before = scroll.scrollY
|
||||||
|
withWheel(1f) { event ->
|
||||||
|
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||||
|
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
|
||||||
|
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||||
|
}
|
||||||
|
val afterUp = scroll.scrollY
|
||||||
|
withWheel(-1f) { event ->
|
||||||
|
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||||
|
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
|
||||||
|
val before = scroll.scrollY
|
||||||
|
withWheel(1f) { event ->
|
||||||
|
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||||
|
assertTrue(scroll.scrollY < before)
|
||||||
|
}
|
||||||
|
val afterUp = scroll.scrollY
|
||||||
|
withWheel(-1f) { event ->
|
||||||
|
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||||
|
assertTrue(scroll.scrollY > afterUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
|
||||||
|
scroll.setScrollEnabled(false)
|
||||||
|
val before = scroll.scrollY
|
||||||
|
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
|
||||||
|
assertEquals(before, scroll.scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
||||||
val before = scroll.scrollY
|
val before = scroll.scrollY
|
||||||
assertTrue(text.requestFocus())
|
assertTrue(text.requestFocus())
|
||||||
@@ -70,7 +106,7 @@ class ScrollSelectionTest {
|
|||||||
val appContext = instrumentation.targetContext
|
val appContext = instrumentation.targetContext
|
||||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
val reactContext = BridgeReactContext(appContext)
|
val reactContext = ScrollTestContext(appContext)
|
||||||
val context = ThemedReactContext(reactContext, appContext)
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
val scroll = manager.createViewInstance(context)
|
val scroll = manager.createViewInstance(context)
|
||||||
@@ -98,4 +134,26 @@ class ScrollSelectionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||||
|
|
||||||
|
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
|
||||||
|
val time = SystemClock.uptimeMillis()
|
||||||
|
val properties = MotionEvent.PointerProperties().apply {
|
||||||
|
id = 0
|
||||||
|
toolType = MotionEvent.TOOL_TYPE_MOUSE
|
||||||
|
}
|
||||||
|
val coordinates = MotionEvent.PointerCoords().apply {
|
||||||
|
x = 150f
|
||||||
|
y = 150f
|
||||||
|
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
|
||||||
|
}
|
||||||
|
val event = MotionEvent.obtain(
|
||||||
|
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
|
||||||
|
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
check(event)
|
||||||
|
} finally {
|
||||||
|
event.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package sh.paseo.scroll;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.facebook.react.bridge.BridgeReactContext;
|
||||||
|
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||||
|
|
||||||
|
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||||
|
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||||
|
ScrollTestContext(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EventDispatcher getEventDispatcher() {
|
||||||
|
return BlackHoleEventDispatcher.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-6
@@ -2,6 +2,8 @@ package sh.paseo.scroll
|
|||||||
|
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.MotionEvent
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
@@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
|||||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||||
private val transformValues = FloatArray(9)
|
private val transformValues = FloatArray(9)
|
||||||
|
|
||||||
|
private fun isVerticallyInverted(): Boolean {
|
||||||
|
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
|
||||||
|
// rotation with positive scaleY. Read the resulting transform, not scaleY.
|
||||||
|
matrix.getValues(transformValues)
|
||||||
|
return transformValues[Matrix.MSCALE_Y] < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
|
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
|
||||||
|
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
if (!scrollEnabled) return false
|
||||||
|
|
||||||
|
// Android applies wheel axes directly to scrollY; the inversion transform only
|
||||||
|
// transforms pointer coordinates. Reverse the vertical axis before delegating so
|
||||||
|
// platform scroll bounds and scroll events still apply.
|
||||||
|
val properties = Array(event.pointerCount) { index ->
|
||||||
|
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
|
||||||
|
}
|
||||||
|
val coordinates = Array(event.pointerCount) { index ->
|
||||||
|
MotionEvent.PointerCoords().also {
|
||||||
|
event.getPointerCoords(index, it)
|
||||||
|
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val correctedEvent = MotionEvent.obtain(
|
||||||
|
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
|
||||||
|
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
|
||||||
|
event.deviceId, event.edgeFlags, event.source, event.flags,
|
||||||
|
)
|
||||||
|
return try {
|
||||||
|
super.onGenericMotionEvent(correctedEvent)
|
||||||
|
} finally {
|
||||||
|
correctedEvent.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||||
// movement to user gestures and explicit scroll commands.
|
// movement to user gestures and explicit scroll commands.
|
||||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
|
||||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||||
matrix.getValues(transformValues)
|
|
||||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
|
||||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -1,21 +1,21 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"output": "स्ते",
|
"output": "स्ते",
|
||||||
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "क्ष",
|
"output": "क्ष",
|
||||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "নক্ষত্র",
|
"output": "নক্ষত্র",
|
||||||
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||||
"clusterStarts": [0, 1, 3, 5, 6, 7],
|
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "क्अ",
|
"output": "क्अ",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "अ्क",
|
"output": "अ्क",
|
||||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-8
@@ -1,6 +1,5 @@
|
|||||||
package sh.paseo.terminal
|
package sh.paseo.terminal
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
@@ -10,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
@@ -43,18 +43,45 @@ class TerminalRowLayoutTest {
|
|||||||
val output = row.getString("output")
|
val output = row.getString("output")
|
||||||
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
||||||
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
||||||
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
|
|
||||||
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
|
|
||||||
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
|
|
||||||
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
|
|
||||||
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
||||||
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
|
||||||
|
val red = 0xffff0000.toInt()
|
||||||
|
val green = 0xff00aa00.toInt()
|
||||||
|
val overlay = 0x472684ff
|
||||||
|
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
|
||||||
|
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
|
||||||
|
paints.selectionBackground = overlay
|
||||||
|
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||||
|
val actual = raster { canvas ->
|
||||||
|
canvas.drawColor(terminalBackground)
|
||||||
|
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||||
|
}
|
||||||
|
val expected = raster { canvas ->
|
||||||
|
canvas.drawColor(terminalBackground)
|
||||||
|
canvas.drawColor(green)
|
||||||
|
canvas.drawColor(overlay)
|
||||||
|
}
|
||||||
|
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||||
|
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
|
||||||
|
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
|
||||||
|
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||||
|
val actual = raster { canvas ->
|
||||||
|
canvas.drawColor(terminalBackground)
|
||||||
|
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||||
|
}
|
||||||
|
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
|
||||||
|
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun starts(row: JSONObject, key: String): List<Int> =
|
private fun starts(row: JSONObject, key: String): List<Int> =
|
||||||
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
|
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
|
||||||
|
|
||||||
|
|||||||
-1
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
|
|||||||
backgroundColor = argb(value.background)
|
backgroundColor = argb(value.background)
|
||||||
cursorColor = argb(value.cursor)
|
cursorColor = argb(value.cursor)
|
||||||
paints.selectionBackground = argb(value.selectionBackground)
|
paints.selectionBackground = argb(value.selectionBackground)
|
||||||
paints.selectionForeground = argb(value.selectionForeground)
|
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
|
|||||||
@Field val background: Double = 0.0
|
@Field val background: Double = 0.0
|
||||||
@Field val cursor: Double = 0.0
|
@Field val cursor: Double = 0.0
|
||||||
@Field val selectionBackground: Double = 0.0
|
@Field val selectionBackground: Double = 0.0
|
||||||
@Field val selectionForeground: Double = 0.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TerminalCustomGlyphRecord : Record {
|
class TerminalCustomGlyphRecord : Record {
|
||||||
|
|||||||
+16
-20
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
|
|||||||
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
|
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
|
||||||
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
|
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
|
||||||
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
|
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
|
||||||
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
|
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
|
||||||
*/
|
*/
|
||||||
internal class TerminalRowLayout(
|
internal class TerminalRowLayout(
|
||||||
val source: TerminalRowSource,
|
val source: TerminalRowSource,
|
||||||
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
|
|||||||
canvas.drawRenderNode(recorded)
|
canvas.drawRenderNode(recorded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
|
/** Draws backgrounds, the selection overlay, then text with its original colors. */
|
||||||
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
|
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
|
||||||
drawContent(canvas, row, selection, paints)
|
drawContent(canvas, row, selection, paints)
|
||||||
}
|
}
|
||||||
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
|
|||||||
val cellHeight = font.cellHeightPx.toFloat()
|
val cellHeight = font.cellHeightPx.toFloat()
|
||||||
val fill = paints.fill
|
val fill = paints.fill
|
||||||
for (cell in 0 until cellCount) {
|
for (cell in 0 until cellCount) {
|
||||||
val background =
|
val left = cellCol[cell] * cellWidth
|
||||||
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
|
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
|
||||||
if (background ushr 24 == 0) continue
|
val background = cellBackground[cell]
|
||||||
|
if (background ushr 24 != 0) {
|
||||||
fill.color = background
|
fill.color = background
|
||||||
canvas.drawRect(
|
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||||
cellCol[cell] * cellWidth,
|
}
|
||||||
0f,
|
if (selected(row, selection, cell)) {
|
||||||
(cellCol[cell] + cellSpan[cell]) * cellWidth,
|
fill.color = paints.selectionBackground
|
||||||
cellHeight,
|
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||||
fill,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val baseline = font.baselinePx.toFloat()
|
val baseline = font.baselinePx.toFloat()
|
||||||
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
|
|||||||
val paint = font.paint(cellFlags[group.firstCell])
|
val paint = font.paint(cellFlags[group.firstCell])
|
||||||
var first = group.firstCell
|
var first = group.firstCell
|
||||||
while (first <= group.lastCell) {
|
while (first <= group.lastCell) {
|
||||||
val color = foreground(row, selection, first, paints)
|
val color = cellForeground[first]
|
||||||
var last = first
|
var last = first
|
||||||
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
|
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
|
||||||
// Only edges between differently colored cells clip; the group's outer edges may overhang.
|
// Only edges between differently colored cells clip; the group's outer edges may overhang.
|
||||||
val clipped = first > group.firstCell || last < group.lastCell
|
val clipped = first > group.firstCell || last < group.lastCell
|
||||||
canvas.save()
|
canvas.save()
|
||||||
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
|
|||||||
for (cell in 0 until cellCount) {
|
for (cell in 0 until cellCount) {
|
||||||
val glyph = cellGlyph[cell]
|
val glyph = cellGlyph[cell]
|
||||||
val flags = cellFlags[cell]
|
val flags = cellFlags[cell]
|
||||||
val foreground = foreground(row, selection, cell, paints)
|
val foreground = cellForeground[cell]
|
||||||
if (glyph != null) {
|
if (glyph != null) {
|
||||||
fill.color = foreground
|
fill.color = foreground
|
||||||
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
|
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
|
||||||
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
|
|||||||
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
|
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
|
|
||||||
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
|
|
||||||
|
|
||||||
/** The row's text as accessibility reads it. */
|
/** The row's text as accessibility reads it. */
|
||||||
fun describe(): String = source.text.trimEnd()
|
fun describe(): String = source.text.trimEnd()
|
||||||
}
|
}
|
||||||
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
|
|||||||
/** Paint state shared by every row of one view. */
|
/** Paint state shared by every row of one view. */
|
||||||
internal class TerminalPaints {
|
internal class TerminalPaints {
|
||||||
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
var selectionBackground: Int = 0xFFFFFF00.toInt()
|
var selectionBackground: Int = 0x472684FF
|
||||||
var selectionForeground: Int = 0xFF000000.toInt()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
|
|||||||
background: number;
|
background: number;
|
||||||
cursor: number;
|
cursor: number;
|
||||||
selectionBackground: number;
|
selectionBackground: number;
|
||||||
selectionForeground: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaseoTerminalCellMetrics {
|
export interface PaseoTerminalCellMetrics {
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
|
|||||||
private var background = TerminalColor(Double(0xff00_0000))
|
private var background = TerminalColor(Double(0xff00_0000))
|
||||||
private var cursorColor = TerminalColor(Double(0xffff_ffff))
|
private var cursorColor = TerminalColor(Double(0xffff_ffff))
|
||||||
private var rowPaint = TerminalRowPaint(
|
private var rowPaint = TerminalRowPaint(
|
||||||
selectionBackground: TerminalColor(Double(0xffff_ff00)),
|
selectionBackground: TerminalColor(Double(0x4726_84ff)))
|
||||||
selectionForeground: TerminalColor(Double(0xff00_0000)))
|
|
||||||
private var rowCache: [String: TerminalRowLayout] = [:]
|
private var rowCache: [String: TerminalRowLayout] = [:]
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
|
|||||||
background = TerminalColor(record.background)
|
background = TerminalColor(record.background)
|
||||||
cursorColor = TerminalColor(record.cursor)
|
cursorColor = TerminalColor(record.cursor)
|
||||||
rowPaint = TerminalRowPaint(
|
rowPaint = TerminalRowPaint(
|
||||||
selectionBackground: TerminalColor(record.selectionBackground),
|
selectionBackground: TerminalColor(record.selectionBackground))
|
||||||
selectionForeground: TerminalColor(record.selectionForeground))
|
|
||||||
backgroundColor = UIColor(cgColor: background.cgColor())
|
backgroundColor = UIColor(cgColor: background.cgColor())
|
||||||
setNeedsDisplay()
|
setNeedsDisplay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
|
|||||||
@Field var background: Double = 0
|
@Field var background: Double = 0
|
||||||
@Field var cursor: Double = 0
|
@Field var cursor: Double = 0
|
||||||
@Field var selectionBackground: Double = 0
|
@Field var selectionBackground: Double = 0
|
||||||
@Field var selectionForeground: Double = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TerminalCustomGlyphRecord: Record {
|
struct TerminalCustomGlyphRecord: Record {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
|
|||||||
/// Colors a row is painted with besides its own styles.
|
/// Colors a row is painted with besides its own styles.
|
||||||
struct TerminalRowPaint {
|
struct TerminalRowPaint {
|
||||||
let selectionBackground: TerminalColor
|
let selectionBackground: TerminalColor
|
||||||
let selectionForeground: TerminalColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row shaped once and placed on the terminal grid.
|
/// One row shaped once and placed on the terminal grid.
|
||||||
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
|
|||||||
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
|
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
|
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
|
||||||
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws the row with its top edge at `top`, selected cells in selection colors.
|
|
||||||
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
|
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
|
||||||
let cellWidth = font.cellWidth
|
let cellWidth = font.cellWidth
|
||||||
let cellHeight = font.cellHeight
|
let cellHeight = font.cellHeight
|
||||||
for cell in 0..<cellCount {
|
for cell in 0..<cellCount {
|
||||||
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
|
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
|
||||||
guard background.isPainted else { continue }
|
let background = cellBackground[cell]
|
||||||
|
if background.isPainted {
|
||||||
context.setFillColor(background.cgColor())
|
context.setFillColor(background.cgColor())
|
||||||
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
|
context.fill(rect)
|
||||||
|
}
|
||||||
|
if isSelected(cell, row: row, selection: selection) {
|
||||||
|
context.setFillColor(paint.selectionBackground.cgColor())
|
||||||
|
context.fill(rect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let centerY = top + cellHeight / 2
|
let centerY = top + cellHeight / 2
|
||||||
let baselineInTextSpace = cellHeight / 2 - font.baseline
|
let baselineInTextSpace = cellHeight / 2 - font.baseline
|
||||||
// A group whose cells differ in color or selection is drawn once per color, clipped to those
|
// A group whose cells differ in color is drawn once per color, clipped to those
|
||||||
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
|
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
|
||||||
for group in groups {
|
for group in groups {
|
||||||
var first = group.firstCell
|
var first = group.firstCell
|
||||||
while first <= group.lastCell {
|
while first <= group.lastCell {
|
||||||
let color = foreground(first, row: row, selection: selection, paint: paint)
|
let color = cellForeground[first]
|
||||||
var last = first
|
var last = first
|
||||||
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
|
while last < group.lastCell && cellForeground[last + 1] == color {
|
||||||
last += 1
|
last += 1
|
||||||
}
|
}
|
||||||
context.saveGState()
|
context.saveGState()
|
||||||
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
|
|||||||
|
|
||||||
let baseline = top + font.baseline
|
let baseline = top + font.baseline
|
||||||
for cell in 0..<cellCount {
|
for cell in 0..<cellCount {
|
||||||
let color = foreground(cell, row: row, selection: selection, paint: paint)
|
let color = cellForeground[cell]
|
||||||
let left = CGFloat(cellCol[cell]) * cellWidth
|
let left = CGFloat(cellCol[cell]) * cellWidth
|
||||||
let width = CGFloat(cellSpan[cell]) * cellWidth
|
let width = CGFloat(cellSpan[cell]) * cellWidth
|
||||||
if let glyph = cellGlyph[cell] {
|
if let glyph = cellGlyph[cell] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||||
"build:apk": "npm --prefix ../.. run build:apk",
|
"build:apk": "npm --prefix ../.. run build:apk",
|
||||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
@@ -71,13 +71,14 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||||
"@xterm/addon-image": "^0.10.0-beta.213",
|
"@xterm/addon-image": "0.10.0-beta.331",
|
||||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||||
|
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
||||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
"@xterm/addon-webgl": "0.20.0-beta.329",
|
||||||
"@xterm/xterm": "6.1.0-beta.292",
|
"@xterm/xterm": "6.1.0-beta.336",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"expo": "~57.0.30",
|
"expo": "~57.0.30",
|
||||||
"expo-asset": "~57.0.20",
|
"expo-asset": "~57.0.20",
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
"@vitest/browser": "^4.1.7",
|
"@vitest/browser": "^4.1.7",
|
||||||
"@vitest/browser-playwright": "^4.1.7",
|
"@vitest/browser-playwright": "^4.1.7",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/headless": "^6.1.0-beta.213",
|
"@xterm/headless": "6.1.0-beta.334",
|
||||||
"babel-preset-expo": "~57.0.16",
|
"babel-preset-expo": "~57.0.16",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
|
|||||||
Vendored
+48
@@ -1,8 +1,16 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||||
|
export const sessionCommandQueryPolicy = {
|
||||||
|
staleTime: 600_000,
|
||||||
|
gcTime: 1_800_000,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface AgentCommandsDraftConfig {
|
export interface AgentCommandsDraftConfig {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) {
|
|||||||
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
|
||||||
|
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
|
||||||
|
export function invalidateCommandsForProviderChange(input: {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string | null;
|
||||||
|
previous: readonly ProviderSnapshotEntry[] | undefined;
|
||||||
|
current: readonly ProviderSnapshotEntry[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
|
||||||
|
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
|
||||||
|
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
|
||||||
|
entry && {
|
||||||
|
status: entry.status,
|
||||||
|
enabled: entry.enabled,
|
||||||
|
source: entry.source,
|
||||||
|
models: entry.models,
|
||||||
|
modes: entry.modes,
|
||||||
|
defaultModeId: entry.defaultModeId,
|
||||||
|
};
|
||||||
|
return input.queryClient.invalidateQueries({
|
||||||
|
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||||
|
predicate: (query) => {
|
||||||
|
const isDraft = query.queryKey[2] === "draft";
|
||||||
|
const provider =
|
||||||
|
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
|
||||||
|
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
|
||||||
|
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
|
||||||
|
return false;
|
||||||
|
if (typeof provider !== "string") return false;
|
||||||
|
const before = previous.get(provider);
|
||||||
|
const after = current.get(provider);
|
||||||
|
if (!before) return after?.status === "ready" && query.state.status === "error";
|
||||||
|
if (before.status !== "ready")
|
||||||
|
return after?.status === "ready" && query.state.status === "error";
|
||||||
|
return !equal(relevant(before), relevant(after));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
||||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ComposerEditingSession } from "@/composer/editing-session";
|
||||||
|
import { agentCommandsQueryKey } from "./cache";
|
||||||
|
import {
|
||||||
|
useAgentCommandsQuery,
|
||||||
|
type AgentSlashCommand,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
} from "./use-query";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AgentSlashCommand,
|
||||||
|
DraftCommandConfig,
|
||||||
|
DraftCommandTarget,
|
||||||
|
AgentCommandsClient,
|
||||||
|
} from "./use-query";
|
||||||
|
export { fetchAgentCommands } from "./use-query";
|
||||||
|
|
||||||
|
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
|
||||||
|
const QueryContext = createContext<CommandQuery | null>(null);
|
||||||
|
export interface CommandHints {
|
||||||
|
names: ReadonlySet<string>;
|
||||||
|
insert(command: string): void;
|
||||||
|
}
|
||||||
|
const HintsContext = createContext<CommandHints | null>(null);
|
||||||
|
|
||||||
|
function useLiveAgentCommands() {
|
||||||
|
return useContext(QueryContext);
|
||||||
|
}
|
||||||
|
export function useAutocompleteCommands(input: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
draftConfig?: DraftCommandConfig;
|
||||||
|
}) {
|
||||||
|
const live = useLiveAgentCommands();
|
||||||
|
const fallback = useAgentCommandsQuery({
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
enabled: !live && input.enabled,
|
||||||
|
draftConfig: input.draftConfig,
|
||||||
|
});
|
||||||
|
return live ?? fallback;
|
||||||
|
}
|
||||||
|
export function useAgentCommandHints() {
|
||||||
|
return useContext(HintsContext);
|
||||||
|
}
|
||||||
|
export function matchCommandLink(
|
||||||
|
url: string,
|
||||||
|
sourceType: "markdown" | "text" | "inline-code" | undefined,
|
||||||
|
names: ReadonlySet<string>,
|
||||||
|
): string | null {
|
||||||
|
return (sourceType === "markdown" || sourceType === "inline-code") &&
|
||||||
|
url.startsWith("/") &&
|
||||||
|
names.has(url.slice(1))
|
||||||
|
? url
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentCommandsProvider({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
editingSession,
|
||||||
|
enabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
editingSession: ComposerEditingSession;
|
||||||
|
enabled: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const namesKey = JSON.stringify(
|
||||||
|
[...new Set(query.commands.map((command) => command.name))].sort(),
|
||||||
|
);
|
||||||
|
const names = useMemo<ReadonlySet<string>>(
|
||||||
|
() => new Set(JSON.parse(namesKey) as string[]),
|
||||||
|
[namesKey],
|
||||||
|
);
|
||||||
|
const insert = useCallback(
|
||||||
|
(token: string) => {
|
||||||
|
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
|
||||||
|
agentCommandsQueryKey({ serverId, agentId }),
|
||||||
|
);
|
||||||
|
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
|
||||||
|
editingSession.insertCommand(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, editingSession, queryClient, serverId],
|
||||||
|
);
|
||||||
|
const hints = useMemo(() => ({ names, insert }), [names, insert]);
|
||||||
|
return (
|
||||||
|
<QueryContext.Provider value={enabled ? query : null}>
|
||||||
|
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
|
||||||
|
</QueryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+51
-1
@@ -1,11 +1,61 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||||
|
import { fetchQueryOptions } from "@/data/query";
|
||||||
import {
|
import {
|
||||||
agentCommandsQueryKey,
|
agentCommandsQueryKey,
|
||||||
agentCommandsQueryRoot,
|
agentCommandsQueryRoot,
|
||||||
draftAgentCommandsQueryKey,
|
draftAgentCommandsQueryKey,
|
||||||
normalizeAgentCommandsCwd,
|
normalizeAgentCommandsCwd,
|
||||||
sessionAgentCommandsQueryKey,
|
sessionAgentCommandsQueryKey,
|
||||||
} from "./agent-commands-query";
|
sessionCommandQueryPolicy,
|
||||||
|
} from "./cache";
|
||||||
|
|
||||||
|
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
|
||||||
|
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
|
||||||
|
let calls = 0;
|
||||||
|
let fail = false;
|
||||||
|
let resolveRefresh: (value: string[]) => void = () => {};
|
||||||
|
const refresh = new Promise<string[]>((resolve) => {
|
||||||
|
resolveRefresh = resolve;
|
||||||
|
});
|
||||||
|
const options = fetchQueryOptions({
|
||||||
|
...sessionCommandQueryPolicy,
|
||||||
|
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||||
|
dataShape: "value",
|
||||||
|
queryKey: key,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
calls++;
|
||||||
|
if (fail) throw new Error("offline");
|
||||||
|
return refresh;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.setQueryData(key, ["old"]);
|
||||||
|
const fresh = new QueryObserver(client, options);
|
||||||
|
const unsubscribeFresh = fresh.subscribe(() => {});
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
unsubscribeFresh();
|
||||||
|
client.setQueryData(key, ["old"], { updatedAt: 1 });
|
||||||
|
const first = new QueryObserver(client, options);
|
||||||
|
const second = new QueryObserver(client, options);
|
||||||
|
const unsubscribeFirst = first.subscribe(() => {});
|
||||||
|
const unsubscribeSecond = second.subscribe(() => {});
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(first.getCurrentResult().data).toEqual(["old"]);
|
||||||
|
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
|
||||||
|
resolveRefresh(["new"]);
|
||||||
|
await refresh;
|
||||||
|
await first.refetch({ cancelRefetch: false });
|
||||||
|
expect(second.getCurrentResult().data).toEqual(["new"]);
|
||||||
|
fail = true;
|
||||||
|
await client.invalidateQueries({ queryKey: key });
|
||||||
|
expect(first.getCurrentResult().data).toEqual(["new"]);
|
||||||
|
expect(first.getCurrentResult().isError).toBe(true);
|
||||||
|
unsubscribeFirst();
|
||||||
|
unsubscribeSecond();
|
||||||
|
client.clear();
|
||||||
|
});
|
||||||
|
|
||||||
describe("agent command query keys", () => {
|
describe("agent command query keys", () => {
|
||||||
it("shares draft commands across draft tabs with the same provider setup", () => {
|
it("shares draft commands across draft tabs with the same provider setup", () => {
|
||||||
+7
-5
@@ -1,9 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
|
||||||
type AgentCommandsClient,
|
|
||||||
type DraftCommandConfig,
|
|
||||||
fetchAgentCommands,
|
|
||||||
} from "./use-agent-commands-query";
|
|
||||||
|
|
||||||
type ListCommands = AgentCommandsClient["listCommands"];
|
type ListCommands = AgentCommandsClient["listCommands"];
|
||||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||||
@@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("fetchAgentCommands", () => {
|
describe("fetchAgentCommands", () => {
|
||||||
|
it("rejects a discovery error so a previous catalog is retained", async () => {
|
||||||
|
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
|
||||||
|
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
|
||||||
|
"provider unavailable",
|
||||||
|
);
|
||||||
|
});
|
||||||
it("loads commands for a draft composer without an agent id", async () => {
|
it("loads commands for a draft composer without an agent id", async () => {
|
||||||
const client = createClient(
|
const client = createClient(
|
||||||
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
||||||
+28
-8
@@ -1,12 +1,15 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useFetchQuery } from "@/data/query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
|
import {
|
||||||
|
agentCommandsQueryKey,
|
||||||
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
|
sessionCommandQueryPolicy,
|
||||||
const SESSION_COMMANDS_STALE_TIME = 60_000;
|
type AgentCommandsDraftConfig,
|
||||||
|
} from "./cache";
|
||||||
|
|
||||||
export interface AgentSlashCommand {
|
export interface AgentSlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: {
|
|||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
draftConfig: input.draftConfig,
|
draftConfig: input.draftConfig,
|
||||||
});
|
});
|
||||||
|
if (response.error) throw new Error(response.error);
|
||||||
return response.commands as AgentSlashCommand[];
|
return response.commands as AgentSlashCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +66,16 @@ export function useAgentCommandsQuery({
|
|||||||
const queryEnabled = enabled && retainedPanelActive;
|
const queryEnabled = enabled && retainedPanelActive;
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const { provider, cwd } = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const agent =
|
||||||
|
state.sessions[serverId]?.agents?.get(agentId) ??
|
||||||
|
state.sessions[serverId]?.agentDetails?.get(agentId);
|
||||||
|
return { provider: agent?.provider, cwd: agent?.cwd };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useFetchQuery({
|
||||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -72,7 +84,15 @@ export function useAgentCommandsQuery({
|
|||||||
return fetchAgentCommands({ client, agentId, draftConfig });
|
return fetchAgentCommands({ client, agentId, draftConfig });
|
||||||
},
|
},
|
||||||
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||||
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
|
dataShape: "value",
|
||||||
|
meta: {
|
||||||
|
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||||
|
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||||
|
},
|
||||||
|
...(draftConfig
|
||||||
|
? { immutableWhen: () => true as const }
|
||||||
|
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||||
|
subscribed: queryEnabled,
|
||||||
retry: 3,
|
retry: 3,
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||||
});
|
});
|
||||||
@@ -84,7 +104,7 @@ export function useAgentCommandsQuery({
|
|||||||
return {
|
return {
|
||||||
commands: query.data ?? [],
|
commands: query.data ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError && query.data === undefined,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
|||||||
import {
|
import {
|
||||||
createControlGeometry,
|
createControlGeometry,
|
||||||
resolveControlInteractionStyles,
|
resolveControlInteractionStyles,
|
||||||
type FieldControlSize,
|
type ControlSize,
|
||||||
} from "@/components/ui/control-geometry";
|
} from "@/components/ui/control-geometry";
|
||||||
import { Field } from "@/components/ui/form-field";
|
import { Field } from "@/components/ui/form-field";
|
||||||
import { identityForeground } from "@/styles/identity-colors";
|
import { identityForeground } from "@/styles/identity-colors";
|
||||||
@@ -153,7 +153,7 @@ export function AgentProfileAppearanceField({
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
onChange: (next: { icon: string; color: string }) => void;
|
onChange: (next: { icon: string; color: string }) => void;
|
||||||
size?: FieldControlSize;
|
size?: ControlSize;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
triggerTestID?: string;
|
triggerTestID?: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user