mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
208
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3ae09926 | ||
|
|
9f68af0274 | ||
|
|
79f2db8d56 | ||
|
|
1efcb31a6c | ||
|
|
ea30d2616a | ||
|
|
f21699a37a | ||
|
|
a9bce9a18d | ||
|
|
73448d483e | ||
|
|
161076a853 | ||
|
|
14845d3ff3 | ||
|
|
616f5e155c | ||
|
|
471db4bdb5 | ||
|
|
68fc16b855 | ||
|
|
ae157dbfb6 | ||
|
|
dc51a3b6a9 | ||
|
|
77798b2e46 | ||
|
|
c8bd4717c9 | ||
|
|
6c1b48d296 | ||
|
|
1a626230d9 | ||
|
|
4b285a113d | ||
|
|
4281372bee | ||
|
|
e79e826e23 | ||
|
|
4928500e1f | ||
|
|
1e2ab3d596 | ||
|
|
2716bd96de | ||
|
|
5abfdf7107 | ||
|
|
6355b339c8 | ||
|
|
8b9c42c266 | ||
|
|
a68dc55490 | ||
|
|
3ed57e9dd7 | ||
|
|
e9f897a66a | ||
|
|
b0a8a5db6d | ||
|
|
412550c36f | ||
|
|
e6064bb374 | ||
|
|
59be8975a4 | ||
|
|
cdd7800fed | ||
|
|
902d43da7c | ||
|
|
92c8c55f31 | ||
|
|
b920243c74 | ||
|
|
9667ec5576 | ||
|
|
72955cef60 | ||
|
|
8dfdb479ab | ||
|
|
5d1a2c10f8 | ||
|
|
a91bb4d406 | ||
|
|
15997a06d0 | ||
|
|
2d5b8eb1fd | ||
|
|
ac775a2904 | ||
|
|
0e7f452c2f | ||
|
|
cab5eedfe3 | ||
|
|
4407283a13 | ||
|
|
4f7813d3aa | ||
|
|
4991efddf8 | ||
|
|
fcbfb4f0d3 | ||
|
|
6221b0c8b7 | ||
|
|
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 | ||
|
|
9c5b47421a | ||
|
|
3b51b8ab2e | ||
|
|
9b3f1bfffd | ||
|
|
5ea639d9b2 | ||
|
|
9c926ed126 | ||
|
|
7b03f0ad21 | ||
|
|
0a549945b5 | ||
|
|
0be49c0620 | ||
|
|
186c132151 | ||
|
|
a4f696620b | ||
|
|
184ab66cde | ||
|
|
fbe24fcdb6 | ||
|
|
787edb1bf9 | ||
|
|
385c29b655 | ||
|
|
9477fe0c00 | ||
|
|
531370c4ce | ||
|
|
e299b21e94 | ||
|
|
1f79efcd62 | ||
|
|
e3682ba18c | ||
|
|
a8e0effb32 | ||
|
|
c663630bbd | ||
|
|
deb1bdc266 | ||
|
|
9c7925de58 | ||
|
|
4351445467 | ||
|
|
9503537041 | ||
|
|
1aef1ec662 | ||
|
|
946b6e58fd | ||
|
|
ac6e4e9f96 | ||
|
|
c4b4516890 | ||
|
|
75f58c4fe7 | ||
|
|
fb69bf3314 | ||
|
|
f7eef9dd0c | ||
|
|
7d74ae5d6c | ||
|
|
61841d1ed5 | ||
|
|
7c88a48be4 | ||
|
|
3196260bd4 | ||
|
|
870a44bc7f | ||
|
|
e8201ab66b | ||
|
|
e12feb633f | ||
|
|
304cbde651 | ||
|
|
0feaf0d987 | ||
|
|
d3f36e92a5 | ||
|
|
1467121b16 | ||
|
|
2f0fb2a8ba | ||
|
|
83ed52669d | ||
|
|
5cfe8f1218 | ||
|
|
38506ed372 | ||
|
|
abc97e0545 | ||
|
|
33faacc055 | ||
|
|
8537709e72 | ||
|
|
9fb7808191 |
@@ -3,7 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
# Every base branch, so stacked PRs and PRs into any base get the same checks.
|
||||||
pull_request:
|
pull_request:
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -281,10 +281,10 @@ jobs:
|
|||||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||||
# bound runtime.
|
# bound runtime.
|
||||||
server-tests-macos:
|
server-tests-macos:
|
||||||
name: server-tests (macos-14, file observation)
|
name: server-tests (macos-latest, file observation)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||||
runs-on: macos-14
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: macos-14
|
- runner: macos-latest
|
||||||
electron_arch: arm64
|
electron_arch: arm64
|
||||||
node_heap_mb: 4096
|
node_heap_mb: 4096
|
||||||
- runner: macos-15-intel
|
- runner: macos-26-intel
|
||||||
electron_arch: x64
|
electron_arch: x64
|
||||||
node_heap_mb: 8192
|
node_heap_mb: 8192
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
run: nix build .#desktop -o result-desktop
|
run: nix build .#desktop -o result-desktop
|
||||||
|
|
||||||
build-desktop-darwin:
|
build-desktop-darwin:
|
||||||
runs-on: macos-14
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+115
-8
@@ -1,6 +1,113 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.14.0-beta.1 - 2026-10-27
|
## 0.15.3 - 2026-11-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
|
||||||
|
|
||||||
|
## 0.15.2 - 2026-11-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||||
|
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||||
|
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
|
||||||
|
## 0.15.1 - 2026-11-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||||
|
|
||||||
|
## 0.15.0 - 2026-11-03
|
||||||
|
|
||||||
|
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 ([#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
|
||||||
|
|
||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
@@ -10,9 +117,8 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 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))
|
|
||||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
@@ -22,10 +128,9 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- 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))
|
|
||||||
|
|
||||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
@@ -34,7 +139,6 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -51,17 +155,20 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||||
|
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- Voice mode now defaults to off; enable it in Voice settings
|
- Voice mode now defaults to off; enable it in Voice settings
|
||||||
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
|
||||||
|
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
## 0.13.2 - 2026-10-26
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
+116
-8
@@ -1,6 +1,113 @@
|
|||||||
# 更新日志
|
# Changelog
|
||||||
|
|
||||||
## 0.14.0-beta.1 - 2026-10-27
|
## 0.15.3 - 2026-11-06
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
|
||||||
|
|
||||||
|
## 0.15.2 - 2026-11-05
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||||
|
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||||
|
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||||
|
|
||||||
|
## 0.15.1 - 2026-11-05
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||||
|
|
||||||
|
## 0.15.0 - 2026-11-03
|
||||||
|
|
||||||
|
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||||
|
|
||||||
|
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||||
|
|
||||||
|
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 点击回复中的已知 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
|
||||||
|
|
||||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|
||||||
@@ -11,7 +118,7 @@
|
|||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
@@ -21,10 +128,9 @@
|
|||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|
||||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
|
||||||
|
|
||||||
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
@@ -33,7 +139,6 @@
|
|||||||
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
@@ -50,17 +155,20 @@
|
|||||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||||
|
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|
||||||
- 语音模式默认关闭,可在语音设置中启用
|
- 语音模式默认关闭,可在语音设置中启用
|
||||||
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
|
||||||
|
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
## 0.13.2 - 2026-10-26
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -52,7 +53,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
|
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
|
||||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||||
|
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
|
||||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
||||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||||
@@ -117,10 +120,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
|
|
||||||
## Release branches
|
## Release branches
|
||||||
|
|
||||||
When the user says "this goes to next", create or
|
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
|
||||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
release builds in a short-lived release workspace from `origin/main` or a release tag; see
|
||||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
[release base and source](docs/release.md#release-base-and-source).
|
||||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
|
||||||
|
|
||||||
## Critical rules
|
## Critical rules
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
|
|||||||
|
|
||||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||||
|
|
||||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repo = path.resolve(root, "../..");
|
const repo = path.resolve(root, "../..");
|
||||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||||
const output = path.join(root, "output");
|
const output = path.join(root, "output");
|
||||||
|
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||||
|
|
||||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||||
@@ -116,6 +117,7 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
device: "iphone",
|
device: "iphone",
|
||||||
|
androidScreenshots: "phoneScreenshots",
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 2560,
|
height: 2560,
|
||||||
textTop: 150,
|
textTop: 150,
|
||||||
@@ -134,6 +136,7 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
"google-tablet": {
|
"google-tablet": {
|
||||||
device: "ipad",
|
device: "ipad",
|
||||||
|
androidScreenshots: "tenInchScreenshots",
|
||||||
width: 1800,
|
width: 1800,
|
||||||
height: 3200,
|
height: 3200,
|
||||||
textTop: 200,
|
textTop: 200,
|
||||||
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
|
|||||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||||
|
// which holds links to these renders. Rewriting the links on every render keeps
|
||||||
|
// added, removed and renamed panels in step with the App Store.
|
||||||
|
async function linkAndroidScreenshots(folder, files) {
|
||||||
|
const dir = path.join(androidImages, folder);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderPreview(rows) {
|
async function renderPreview(rows) {
|
||||||
const tile = 440;
|
const tile = 440;
|
||||||
const gap = 40;
|
const gap = 40;
|
||||||
@@ -387,7 +402,9 @@ try {
|
|||||||
const marks = await loadAgentMarks(work);
|
const marks = await loadAgentMarks(work);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [name, store] of Object.entries(STORES)) {
|
for (const [name, store] of Object.entries(STORES)) {
|
||||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
const files = await renderStore(browser, name, store, font, marks, work);
|
||||||
|
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||||
|
rows.push(files);
|
||||||
}
|
}
|
||||||
await renderPreview(rows);
|
await renderPreview(rows);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+97
-18
@@ -12,6 +12,27 @@ initializing → idle → running → idle (or error → closed)
|
|||||||
|
|
||||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||||
|
|
||||||
|
## Daemon restart recovery
|
||||||
|
|
||||||
|
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
|
||||||
|
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
|
||||||
|
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.
|
||||||
|
|
||||||
|
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||||
|
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
|
||||||
|
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
|
||||||
|
|
||||||
|
Startup chooses candidates before anything else reads or repairs persisted running state, then
|
||||||
|
opens connections and resumes every candidate in the background. A provider that is slow to reopen
|
||||||
|
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
|
||||||
|
closing runtimes.
|
||||||
|
|
||||||
|
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
|
||||||
|
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
|
||||||
|
loop or exactly-once turn guarantee.
|
||||||
|
|
||||||
## Runtime residency
|
## Runtime residency
|
||||||
|
|
||||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||||
@@ -115,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
|||||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||||
normal root agent immediately, and closing its still-open tab archives it.
|
normal root agent immediately, and closing its still-open tab archives it.
|
||||||
|
|
||||||
|
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
|
||||||
|
This is informational ancestry for future workspace grouping. It does not change placement,
|
||||||
|
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
|
||||||
|
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
|
||||||
|
never inferred from directories, projects, or Git branches.
|
||||||
|
|
||||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||||
|
|
||||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||||
@@ -139,8 +166,10 @@ caller, child, and host into one watch.
|
|||||||
|
|
||||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
|
||||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
recovery is observed only after its resume was sent, so the parent never hears that it was closed
|
||||||
|
in between. Archived or deleted parents suppress delivery;
|
||||||
|
parent-owned creation watches also stop
|
||||||
reporting after detachment.
|
reporting after detachment.
|
||||||
|
|
||||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||||
@@ -160,23 +189,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
|
||||||
|
|
||||||
@@ -211,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
|||||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||||
|
|
||||||
|
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
|
||||||
|
The daemon runs one archive lane per workspace, shared by every connection
|
||||||
|
(`packages/server/src/server/workspace-archive-operations.ts`):
|
||||||
|
|
||||||
|
| A request arrives while | Result |
|
||||||
|
| ------------------------ | --------------------------------------------------------- |
|
||||||
|
| an archive runs: archive | joins the running archive |
|
||||||
|
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
|
||||||
|
| an undo runs: archive | waits for the undo, then archives |
|
||||||
|
| an undo runs: undo | joins the running undo |
|
||||||
|
|
||||||
|
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
|
||||||
|
sent right behind its archive still cancels it.
|
||||||
|
|
||||||
|
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
|
||||||
|
the agents that archive itself archived. Deleting the worktree directory is the point of no
|
||||||
|
return: an undo after it recreates the worktree through workspace recovery. The client shows or
|
||||||
|
hides the row by the latest local intent, so a late remove or upsert from a superseded request
|
||||||
|
does not flip it back. Tabs and layout are purged only once the undo window closes with the
|
||||||
|
archive committed.
|
||||||
|
|
||||||
History navigation opens the selected agent without changing either archive state. Workspace
|
History navigation opens the selected agent without changing either archive state. Workspace
|
||||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||||
@@ -263,17 +332,27 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
|||||||
|
|
||||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||||
|
|
||||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
|
||||||
|
its selected tab; attention on another agent remains unread until that agent is focused or the
|
||||||
|
workspace is explicitly marked read.
|
||||||
|
|
||||||
Attention is set by the agent finishing or failing and cleared by the client's
|
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
|
||||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
interaction with its chat, including repeat clicks in an already focused pane. A completion
|
||||||
|
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
|
||||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
neither, so resuming carries the stored attention and the stored last-activity time through
|
||||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
||||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||||
|
|
||||||
|
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
|
||||||
|
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
|
||||||
|
green one. For the same reason every new failure raises `error` attention, including a failed reload
|
||||||
|
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
|
||||||
|
agent would show the green dot or none. Subagents track marks are the exception: they follow the
|
||||||
|
child's lifecycle, because the track is where a failed child stays visible.
|
||||||
|
|
||||||
## The subagents track
|
## The subagents track
|
||||||
|
|
||||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Agent stream disclosures
|
||||||
|
|
||||||
|
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||||
|
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||||
|
first painted frame. No animation.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
A disclosure component knows nothing about the list. It wraps its state change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const hold = useDisclosureHold();
|
||||||
|
const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||||
|
```
|
||||||
|
|
||||||
|
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||||
|
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||||
|
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||||
|
|
||||||
|
Following output:
|
||||||
|
|
||||||
|
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||||
|
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||||
|
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||||
|
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||||
|
following.
|
||||||
|
- Scroll events during the hold never re-stick: the correction is a programmatic scroll that
|
||||||
|
would otherwise read as "back at the bottom".
|
||||||
|
|
||||||
|
The trigger can only stay put when the list has scroll room for the change. When the loaded
|
||||||
|
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
||||||
|
ends at its end and the trigger moves by the difference.
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||||
|
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||||
|
|
||||||
|
## Native
|
||||||
|
|
||||||
|
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||||
|
not hold. Summary lines and todo cards expand inline.
|
||||||
|
|
||||||
|
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||||
|
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||||
|
change in the commit that makes it, so a hold needs no anchor neighbour, native child index or
|
||||||
|
render window stretch. The inverted FlatList it replaced needed all three, and three commits per
|
||||||
|
toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||||
|
|
||||||
|
### Who owns the offset
|
||||||
|
|
||||||
|
One owner keeps the scroll offset at any moment:
|
||||||
|
|
||||||
|
| State | Owner |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||||
|
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||||
|
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||||
|
| User scroll | The finger. The controller detaches at drag start. |
|
||||||
|
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||||
|
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||||
|
|
||||||
|
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||||
|
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||||
|
native correction runs on the UI thread around each mount, so a row that grows or lands at the end
|
||||||
|
scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer
|
||||||
|
growing or shrinking) and an explicit jump, both through the native `scrollToEnd`.
|
||||||
|
|
||||||
|
Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end
|
||||||
|
its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of
|
||||||
|
the native end, which the controller reads as the user scrolling away.
|
||||||
|
|
||||||
|
The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list
|
||||||
|
and the composer together on the UI thread, and the composer viewport clips the top of the list
|
||||||
|
while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can
|
||||||
|
only commit after the motion, and while detached or holding nothing corrects for it, so every row
|
||||||
|
jumps by the keyboard height after the keyboard settles.
|
||||||
|
|
||||||
|
### The hold
|
||||||
|
|
||||||
|
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||||
|
|
||||||
|
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||||
|
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||||
|
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||||
|
one down, and a size change with no data change keeps the first row in view whatever
|
||||||
|
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||||
|
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||||
|
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||||
|
2. Run `apply`.
|
||||||
|
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||||
|
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||||
|
later, after the change painted. The trigger puts the measurement and the corrected positions
|
||||||
|
in the toggle's own commit.
|
||||||
|
|
||||||
|
The hold ends two frames after the last item size change, since the list measures the toggled row
|
||||||
|
and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then
|
||||||
|
re-sticks only if the list is within 1 px of its end, the same distance web resumes from.
|
||||||
|
|
||||||
|
### User scroll
|
||||||
|
|
||||||
|
The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below
|
||||||
|
the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for
|
||||||
|
those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while
|
||||||
|
the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's:
|
||||||
|
in the detached state LegendList uses the same native correction, and a history page that lands
|
||||||
|
mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following
|
||||||
|
keeps following.
|
||||||
|
|
||||||
|
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
|
||||||
|
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
|
||||||
|
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
|
||||||
|
with the scroll-to-bottom pill, so the rows the user let go of never move.
|
||||||
|
|
||||||
|
### Draw distance
|
||||||
|
|
||||||
|
`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured
|
||||||
|
blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a
|
||||||
|
15,000 px turn expanded, Android emulator, dev build:
|
||||||
|
|
||||||
|
| List | Blank viewport | Janky frames | p50 / p90 / p99 |
|
||||||
|
| ----------------------------- | -------------------------- | ------------ | ---------------- |
|
||||||
|
| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms |
|
||||||
|
| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | |
|
||||||
|
| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms |
|
||||||
|
|
||||||
|
With every turn collapsed, flinging from a cold open into history did not blank at any value from
|
||||||
|
500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure
|
||||||
|
1000 and 1500 on the expanded chat, on a host under load 10, before lowering it.
|
||||||
|
|
||||||
|
### LegendList patch
|
||||||
|
|
||||||
|
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||||
|
them by purpose, and
|
||||||
|
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||||
|
Drop a hunk when the installed LegendList ships its fix.
|
||||||
|
|
||||||
|
### Scroll view corrections
|
||||||
|
|
||||||
|
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
|
||||||
|
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
|
||||||
|
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
|
||||||
|
|
||||||
|
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
|
||||||
|
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
|
||||||
|
the mount, and React Native then adds the anchor's movement to the clamped offset
|
||||||
|
([RN #58578](https://github.com/react/react-native/issues/58578), fix in
|
||||||
|
[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list
|
||||||
|
follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores
|
||||||
|
the pre-mount offset for the adjustment and clamps after it.
|
||||||
|
|
||||||
|
**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped
|
||||||
|
disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS
|
||||||
|
subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already.
|
||||||
|
React Native 0.88 skips the adjustment for a removed anchor
|
||||||
|
([PR #57294](https://github.com/react/react-native/pull/57294)).
|
||||||
|
|
||||||
|
**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`,
|
||||||
|
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
||||||
|
Native counterpart; it stays while the timeline uses the pin.
|
||||||
|
|
||||||
|
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
||||||
|
their fixes.
|
||||||
@@ -34,10 +34,11 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
|||||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
|
||||||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
- **A history row re-renders only when its item or layout item identity changes.** Every
|
||||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
|
||||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
|
||||||
|
phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||||
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
||||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||||
|
|||||||
+27
-8
@@ -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,14 +174,18 @@ 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
|
## Scroll view corrections
|
||||||
|
|
||||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||||
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
|
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||||
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
|
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
|
||||||
|
```bash
|
||||||
|
cd packages/app/android
|
||||||
|
./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||||
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -215,7 +231,8 @@ fastlane/metadata/android/
|
|||||||
│ ├── images/
|
│ ├── images/
|
||||||
│ │ ├── icon.png (512x512)
|
│ │ ├── icon.png (512x512)
|
||||||
│ │ ├── featureGraphic.png (1024x500)
|
│ │ ├── featureGraphic.png (1024x500)
|
||||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||||
|
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||||
│ └── changelogs/ (generated — see below)
|
│ └── changelogs/ (generated — see below)
|
||||||
├── ja/
|
├── ja/
|
||||||
└── zh-CN/
|
└── zh-CN/
|
||||||
@@ -223,6 +240,8 @@ fastlane/metadata/android/
|
|||||||
|
|
||||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||||
|
|
||||||
|
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||||
|
|
||||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||||
|
|
||||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||||
|
|||||||
@@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
|
|||||||
|
|
||||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||||
|
|
||||||
|
## Outbound networking
|
||||||
|
|
||||||
|
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
|
||||||
|
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
|
||||||
|
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
|
||||||
|
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
|
||||||
|
are treated as unset, with one warning naming the variable in `daemon.log`.
|
||||||
|
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
|
||||||
|
`ALL_PROXY` configurations are not supported.
|
||||||
|
|
||||||
## Deployment models
|
## Deployment models
|
||||||
|
|
||||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -232,7 +262,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
|
|
||||||
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
||||||
|
|
||||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
|
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:<provider-id>`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -260,9 +290,9 @@ Paseo passes those variables through to the Codex app-server process **and** map
|
|||||||
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
model_provider = "my-codex"
|
model_provider = "paseo:my-codex"
|
||||||
|
|
||||||
[model_providers.my-codex]
|
[model_providers."paseo:my-codex"]
|
||||||
name = "My Codex"
|
name = "My Codex"
|
||||||
base_url = "https://custom-relay.example.com/v1"
|
base_url = "https://custom-relay.example.com/v1"
|
||||||
wire_api = "responses"
|
wire_api = "responses"
|
||||||
@@ -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.
|
||||||
|
|||||||
+5
-1
@@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
|
|||||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||||
|
An explicit base selection is stored in the checkout's Git-directory Paseo metadata and
|
||||||
|
mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps
|
||||||
|
recovery possible after the checkout metadata is deleted. A newly opened workspace inherits
|
||||||
|
the checkout's selected base; separate worktrees keep separate bases.
|
||||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||||
@@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
|||||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. |
|
||||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
|||||||
+23
-15
@@ -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,15 @@ 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 where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||||
|
|
||||||
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.
|
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||||
|
|
||||||
|
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
|
||||||
|
|
||||||
|
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||||
|
|
||||||
|
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 where controls are touch-sized, 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 +108,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 +120,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 +132,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.
|
||||||
|
|
||||||
@@ -138,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
|
|||||||
|
|
||||||
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
||||||
|
|
||||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
|
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
|
||||||
|
|
||||||
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
||||||
|
|
||||||
@@ -150,7 +156,7 @@ One row off the rail makes the whole card look unconsidered.
|
|||||||
|
|
||||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||||
|
|
||||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||||
|
|
||||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||||
|
|
||||||
@@ -167,6 +173,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
|
|||||||
|
|
||||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||||
|
|
||||||
|
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
|
||||||
|
|
||||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -197,13 +205,13 @@ 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. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. 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.
|
||||||
|
|
||||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||||
|
|
||||||
@@ -251,7 +259,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 +286,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` |
|
||||||
|
|||||||
+11
-1
@@ -593,7 +593,17 @@ 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`. Report the URL to npm so they purge it.
|
||||||
|
|
||||||
|
A lockfile regeneration is itself how new bad versions enter: the pkijs fix re-resolved the
|
||||||
|
electron-builder tree and pulled in `semver` 5.7.3 and 6.3.2, published two days earlier, and
|
||||||
|
5.7.3 then failed the same way. After editing `overrides`, run `npm install --package-lock-only`
|
||||||
|
and check that the changed `resolved` lines in `package-lock.json` are only the ones you meant.
|
||||||
|
|
||||||
|
A per-parent override such as `"tiny-async-pool": { "semver": "5.7.2" }` applies to that
|
||||||
|
parent's whole subtree, so it can also downgrade a grandchild that needs a different major. Key
|
||||||
|
the child by range (`{ "semver@^5": "5.7.2" }`) so only matching edges are replaced.
|
||||||
|
|
||||||
## ACP provider catalog versions
|
## ACP provider catalog versions
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -58,11 +58,17 @@ 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.
|
||||||
|
|
||||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
|
||||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
When the action names a specific tab, pass it as `target`; that explicit choice
|
||||||
is authoritative. Callers should not choose between separate route and tab
|
is authoritative. Callers should not choose between separate route and tab
|
||||||
navigation APIs.
|
navigation APIs.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -157,8 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
|
|||||||
The dock's private viewport and capacity model are extracted from chat. The
|
The dock's private viewport and capacity model are extracted from chat. The
|
||||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
shift hooks, shift style, translate view, and keyboard anchor. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
|
||||||
at its root mount point outside the portal host when changing dock hosts.
|
at its root mount point outside the portal host when changing dock hosts.
|
||||||
|
|
||||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
||||||
@@ -171,15 +170,19 @@ animation. Keep this platform choice inside the keyboard module's private platfo
|
|||||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||||
slide under the header on open and out from under it on close. Do not swap the
|
slide under the header on open and out from under it on close. Do not swap the
|
||||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||||
band or a paused transition when the inverted list updates its viewport.
|
band or a paused transition when the stream list updates its viewport.
|
||||||
|
|
||||||
|
The docked composer rests at the larger of the safe-area bottom and 16pt. With
|
||||||
|
the keyboard open it keeps 16pt above the keyboard through the translate view's
|
||||||
|
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
|
||||||
|
grow the composer's bottom padding with the keyboard instead.
|
||||||
|
|
||||||
Do not animate a layout prop through the keyboard transition. On Android
|
Do not animate a layout prop through the keyboard transition. On Android
|
||||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||||
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
||||||
layout. Preserve the translated dock's history scroll range with a far-end
|
layout. The stream list gets no keyboard inset either; see
|
||||||
content inset on the inverted stream list. Update that inset only when keyboard
|
[who owns the offset](agent-stream-disclosure.md#who-owns-the-offset).
|
||||||
motion settles; never drive it per frame.
|
|
||||||
|
|
||||||
The translated dock's height is not the composer's available height. Bound the
|
The translated dock's height is not the composer's available height. Bound the
|
||||||
composer against the stationary space below the header inside
|
composer against the stationary space below the header inside
|
||||||
|
|||||||
+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.
|
||||||
@@ -38,6 +39,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||||
|
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
|
||||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Keys shared with text editing
|
||||||
|
|
||||||
|
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||||
|
workspace archive undo, declares `editable: false` and registers its handler with
|
||||||
|
`isActive` true only while it has work. An inactive handler leaves the key
|
||||||
|
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ Native code blocks use the library's copy control and compiled grammars. Web cod
|
|||||||
|
|
||||||
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
||||||
|
|
||||||
|
On iOS a newly mounted Markdown view paints empty for a frame or more after it is laid out at its measured height: the library parses even a view's first content on a background queue and applies it in a later main-queue turn. Expanding a turn in chat therefore shows its rows in place with the text arriving a frame later (165 ms when the mount keeps the main thread busy). Android paints the text in the first frame. Rendering a view's first content synchronously fixes the frame but costs frame time when a fling mounts history rows, so the app keeps the library's behavior until [enriched-markdown #1131](https://github.com/software-mansion/enriched-markdown/issues/1131) is resolved.
|
||||||
|
|
||||||
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
|
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
|
||||||
|
|
||||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||||
|
|||||||
+12
-9
@@ -200,13 +200,15 @@ its own.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
|
||||||
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
|
||||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
after the start. Whatever moved or resized the surface in between was undone: content that
|
||||||
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
|
||||||
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
|
||||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
leaves no inline style; native keeps the Reanimated `Keyframe`.
|
||||||
write moved the open menu back off-screen.
|
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
|
||||||
|
so the entering animation runs at the final position rather than ending unseen at the
|
||||||
|
off-screen measuring position.
|
||||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||||
rarely the height of the page before it. A fill page is the one exception.
|
rarely the height of the page before it. A fill page is the one exception.
|
||||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||||
@@ -224,7 +226,8 @@ its own.
|
|||||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||||
inactive, and that inactive commit is where the surface unmounts.
|
inactive, and that inactive commit is where the surface unmounts.
|
||||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
|
||||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
|
||||||
|
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
|
||||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||||
|
|||||||
+39
-10
@@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations:
|
|||||||
They are one interaction, not two independent drawers. The implementation lives in
|
They are one interaction, not two independent drawers. The implementation lives in
|
||||||
`packages/app/src/mobile-panels/`.
|
`packages/app/src/mobile-panels/`.
|
||||||
|
|
||||||
|
Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or
|
||||||
|
yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar
|
||||||
|
width. The workspace does not move, and the saved dock preference does not change.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
React/Zustand owns the durable intent:
|
React/Zustand owns the durable intent:
|
||||||
@@ -23,6 +27,12 @@ interface MobilePanelSelection {
|
|||||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||||
|
|
||||||
|
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||||
|
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||||
|
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||||
|
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||||
|
would block the navigation swipe, which begins only from `agent`.
|
||||||
|
|
||||||
The UI worklet owns transient motion:
|
The UI worklet owns transient motion:
|
||||||
|
|
||||||
- one normalized position (`-1` left, `0` center, `1` right)
|
- one normalized position (`-1` left, `0` center, `1` right)
|
||||||
@@ -32,8 +42,10 @@ The UI worklet owns transient motion:
|
|||||||
|
|
||||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that
|
||||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no
|
||||||
|
pointer listeners, and an enabled host captures only a pointer its handler still tracks after
|
||||||
|
pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it.
|
||||||
Native worklets own overlay opacity and pointer events while
|
Native worklets own overlay opacity and pointer events while
|
||||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||||
@@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them
|
|||||||
|
|
||||||
## Why one position
|
## Why one position
|
||||||
|
|
||||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
Both transforms and both backdrop opacities are derived from the same normalized position. Panel
|
||||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
width is only a projection input: the window width on the right, and on the left the width
|
||||||
|
`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width
|
||||||
|
clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one
|
||||||
|
value. Rotation changes the projection, not the panel state.
|
||||||
|
|
||||||
This makes these invalid states unrepresentable:
|
This makes these invalid states unrepresentable:
|
||||||
|
|
||||||
@@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update
|
|||||||
while that revision still owns the gesture.
|
while that revision still owns the gesture.
|
||||||
|
|
||||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns
|
||||||
|
the dragged position to its anchor. The older gesture's remaining updates and finish callback are
|
||||||
|
ignored.
|
||||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||||
@@ -80,6 +97,12 @@ definition, no longer eligible to begin.
|
|||||||
|
|
||||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||||
|
- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a
|
||||||
|
mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen
|
||||||
|
(`hooks/pointer-capabilities`) never enable them.
|
||||||
|
- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with
|
||||||
|
terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere.
|
||||||
|
Horizontal-scroll ownership and the open-gesture blocker still take precedence.
|
||||||
- A focused interactive surface may block panel-opening gestures through
|
- A focused interactive surface may block panel-opening gestures through
|
||||||
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
|
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
|
||||||
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
|
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
|
||||||
@@ -87,11 +110,17 @@ definition, no longer eligible to begin.
|
|||||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles
|
||||||
styles in sidebar components.
|
in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching
|
||||||
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
|
between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns
|
||||||
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
|
overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep
|
||||||
removing the sidebar tree.
|
the native overlay laid out while hidden, as on compact layouts. Give the switching host no
|
||||||
|
Reanimated layout props: a width keyed on the presentation reaches Android after React commits the
|
||||||
|
switch, and a lost update left the workspace at the dock width. The dock sizes to its content and
|
||||||
|
the overlay host fills the window.
|
||||||
|
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||||
|
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||||
|
every sidebar destination closes the overlay before it navigates.
|
||||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||||
has caused native crashes.
|
has caused native crashes.
|
||||||
|
|||||||
+15
-10
@@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
|
|||||||
|
|
||||||
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
|
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
|
||||||
|
|
||||||
|
For Android background fold recovery, install the release APK on a foldable AVD, open a
|
||||||
|
connected workspace, close the keyboard, and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
|
||||||
|
changes posture, resumes immediately, and asserts compact/wide workspace controls without
|
||||||
|
restarting the process. It writes snapshots and native failure state under
|
||||||
|
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
|
||||||
|
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
|
||||||
|
reload cannot update it.
|
||||||
|
|
||||||
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
|
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
|
||||||
with an empty prompt, hide its keyboard, and run:
|
with an empty prompt, hide its keyboard, and run:
|
||||||
|
|
||||||
@@ -438,18 +452,9 @@ Regular `View` components can safely use Unistyles dynamic styles — the confli
|
|||||||
|
|
||||||
## Native Chat Stream Layout
|
## Native Chat Stream Layout
|
||||||
|
|
||||||
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
|
|
||||||
|
|
||||||
- chronological stream order
|
|
||||||
- strategy-ordered array order
|
|
||||||
- native inverted cell visual order
|
|
||||||
|
|
||||||
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
||||||
|
|
||||||
Platform-specific stream edges belong on `StreamStrategy`:
|
Platform-specific stream edges belong on `StreamStrategy`. Web and native both render in chronological order, so both use the last history item as the history/live-head boundary and render content before a footer.
|
||||||
|
|
||||||
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
|
|
||||||
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
|
|
||||||
|
|
||||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,6 @@
|
|||||||
|
|
||||||
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
||||||
|
|
||||||
## Android inverted chat
|
|
||||||
|
|
||||||
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
|
|
||||||
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
|
|
||||||
into rotation with positive `scaleY`, so test the composed matrix when deciding
|
|
||||||
whether those requests should move the viewport. Native tests must apply the
|
|
||||||
list's transform through React Native's view manager; assigning `scaleY` directly
|
|
||||||
misses that decomposition.
|
|
||||||
|
|
||||||
## Diagrams in lists and quotes
|
## Diagrams in lists and quotes
|
||||||
|
|
||||||
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
||||||
|
|||||||
+28
-4
@@ -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.
|
||||||
@@ -447,6 +465,12 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
|
|||||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||||
|
|
||||||
|
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
|
||||||
|
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
|
||||||
|
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
|
||||||
|
ownership checks prefer the marker-verified worktree root over the legacy
|
||||||
|
path shape: a plugin can place a worktree several directories below the default worktrees root.
|
||||||
|
|
||||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||||
@@ -544,7 +568,7 @@ string is never inline SVG or a URL.
|
|||||||
|
|
||||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||||
|
|
||||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||||
|
|
||||||
## Contribute sidebar items
|
## Contribute sidebar items
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,47 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
|
|||||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||||
|
|
||||||
|
## Terminal ANSI replay
|
||||||
|
|
||||||
|
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
|
||||||
|
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
|
||||||
|
only subsequent live output, without the existing screen or history.
|
||||||
|
|
||||||
|
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
|
||||||
|
advertising the capability. The SDK keeps the established subscribe request against older
|
||||||
|
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
|
||||||
|
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
|
||||||
|
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
|
||||||
|
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
|
||||||
|
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
|
||||||
|
|
||||||
|
## Binary chunking
|
||||||
|
|
||||||
|
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
|
||||||
|
negotiate connection-scoped framing. Application serializers and routed envelopes use
|
||||||
|
the same boundary. Small messages and handshakes keep their existing representation.
|
||||||
|
Older peers retain existing frame limits.
|
||||||
|
|
||||||
|
The reserved binary opcode `0x31` is followed by version `1`, original kind
|
||||||
|
(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an
|
||||||
|
unchanged slice. There is no total length or chunk count; a future receiver can
|
||||||
|
consume slices incrementally. Today the receiver buffers up to the existing
|
||||||
|
100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave.
|
||||||
|
Disconnect clears partial state and negotiation.
|
||||||
|
|
||||||
|
The lower transport supplies its plaintext frame budget, including encryption
|
||||||
|
overhead. The sender waits for drain between chunks while ordinary messages keep
|
||||||
|
the existing delivery path. Slow-client protections count messages queued behind
|
||||||
|
an active transfer as well as bytes already in the socket.
|
||||||
|
The framing module never selects an encryption construction.
|
||||||
|
|
||||||
|
Native uploads retain the platform's existing queue limits. React Native's
|
||||||
|
WebSocket does not report its queued bytes, so the client cannot wait for native
|
||||||
|
drain. Android's installed OkHttp transport closes when a send would put more
|
||||||
|
than 16 MiB in its queue. The native transport disables outbound chunking, including
|
||||||
|
on routed channels, and retains its existing single-frame admission errors.
|
||||||
|
Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes.
|
||||||
|
|
||||||
## Owned observations
|
## Owned observations
|
||||||
|
|
||||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||||
|
|||||||
+3
-1
@@ -89,7 +89,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.
|
||||||
|
|
||||||
@@ -507,6 +507,8 @@ interface AgentSession {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
|||||||
+156
-80
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
|
|||||||
|
|
||||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||||
|
|
||||||
**Preparation** (local, reversible — agent does this):
|
**Preparation** (reversible — agent does this):
|
||||||
|
|
||||||
- format, lint, typecheck all green
|
- format, lint, typecheck all green
|
||||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
|
||||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||||
target version and rationale to the user
|
target version and rationale to the user
|
||||||
- draft the changelog, show it to the user, wait for review
|
- draft the changelog, show it to the user, wait for review
|
||||||
@@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
|||||||
|
|
||||||
**Go-ahead** (user says "go ahead"):
|
**Go-ahead** (user says "go ahead"):
|
||||||
|
|
||||||
- commit the approved release inputs locally
|
- commit the approved release inputs locally on the release branch
|
||||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
- run the release, which publishes npm and pushes the release branch and tag
|
||||||
- create the release heartbeat immediately and babysit it to completion
|
- create the release heartbeat immediately and babysit it to completion
|
||||||
|
- [reconcile `main`](#reconcile-main) with what shipped
|
||||||
|
|
||||||
Rules that apply to both steps:
|
Rules that apply to both steps:
|
||||||
|
|
||||||
@@ -27,56 +28,128 @@ Rules that apply to both steps:
|
|||||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||||
|
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
|
||||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||||
|
|
||||||
## Release source and CI
|
## Main stays open
|
||||||
|
|
||||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
|
||||||
resolved commit. The default release checkout is a clean local `main` whose
|
finalized. Each release builds in a short-lived release workspace, and its published tag records
|
||||||
`HEAD` equals `origin/main`.
|
the shipped source.
|
||||||
|
|
||||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
## Choosing a path
|
||||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
|
||||||
commit instead of `origin/main`.
|
|
||||||
|
|
||||||
Before making release-preparation commits, confirm the existing CI run for the
|
The user decides when the changes on `main` warrant a release and how much risk it takes:
|
||||||
resolved commit is green. Pending CI is watched to completion. Release
|
|
||||||
preparation then stays local through the changelog, any explicitly requested ACP
|
|
||||||
catalog update, lockfile preparation, and the version commit. After approval,
|
|
||||||
commit the prepared inputs locally and run the release command. Its branch and
|
|
||||||
tag push is the one remote release batch and starts CI for the complete release
|
|
||||||
commit.
|
|
||||||
|
|
||||||
## Release branch discipline
|
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
|
||||||
|
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
|
||||||
|
review often takes about a day, with no guaranteed timing.
|
||||||
|
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
|
||||||
|
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
|
||||||
|
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||||
|
|
||||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
A beta follows these defaults, which the user can explicitly override:
|
||||||
release. This applies to both beta and stable releases.
|
|
||||||
|
|
||||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
|
||||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
|
||||||
PR, and keep that destination through delivery.
|
not count for them.
|
||||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
|
||||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
|
||||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
them.
|
||||||
Pass CI and merge without squashing away the individual PR commits needed for
|
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
|
||||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
|
||||||
`next`. Create it fresh when needed again.
|
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
|
||||||
|
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
|
||||||
|
pace.
|
||||||
|
|
||||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
npm dist-tags are independent pointers:
|
||||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
|
||||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
|
||||||
integration PR. Handle PR
|
|
||||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
|
||||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
|
||||||
|
|
||||||
### Hotfix from a release tag
|
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
|
||||||
|
newest stable.
|
||||||
|
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
|
||||||
|
newer than the version `beta` points to, which covers promotions and direct stable releases, so
|
||||||
|
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
|
||||||
|
`beta` on the active newer beta.
|
||||||
|
|
||||||
If `main` contains changes you do not want to release, branch from the affected
|
## Release base and source
|
||||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
|
||||||
use the normal release flow with it as the explicit source, choosing a new patch
|
The release base is the tag or commit a release workspace starts from. The release source is the
|
||||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
|
||||||
`next`, preserving newer development and version changes there. This is a
|
|
||||||
short-lived hotfix branch, not another maintained release track.
|
| Release | Base | Changes on top |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
|
||||||
|
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
|
||||||
|
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
|
||||||
|
| Promotion | The beta tag being promoted | None by default |
|
||||||
|
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
|
||||||
|
|
||||||
|
An explicit user instruction can select another base.
|
||||||
|
|
||||||
|
1. Fetch `origin` with tags and resolve the base to one commit.
|
||||||
|
2. Create the release workspace through Paseo MCP on a new branch from the base, named
|
||||||
|
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
|
||||||
|
this workspace: `release:push` pushes `HEAD` to the current branch.
|
||||||
|
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
|
||||||
|
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
|
||||||
|
Resolve conflicts within the approved changes and report how. A resolution that needs new
|
||||||
|
behavior is a scope addition: surface it for selection.
|
||||||
|
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
|
||||||
|
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
|
||||||
|
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
|
||||||
|
because CI push runs target only `main`.
|
||||||
|
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
|
||||||
|
version commit local until the go-ahead. The release command pushes the release branch and tag
|
||||||
|
as one batch, and the tag push starts the release workflows. Then run
|
||||||
|
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
|
||||||
|
6. [Reconcile `main`](#reconcile-main).
|
||||||
|
|
||||||
|
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
|
||||||
|
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
|
||||||
|
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
|
||||||
|
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
|
||||||
|
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
|
||||||
|
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
|
||||||
|
for the unchanged content.
|
||||||
|
|
||||||
|
## Release inventory
|
||||||
|
|
||||||
|
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
|
||||||
|
not, then get the batch approved. There is no release label or queue.
|
||||||
|
|
||||||
|
1. Find the commits on `origin/main` whose patches are not in the base tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
|
||||||
|
content over PR state or branch names.
|
||||||
|
|
||||||
|
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
|
||||||
|
prerequisites an included PR depends on and anything that adds scope beyond what the beta
|
||||||
|
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
|
||||||
|
preserves its behavior, directly or through a superseding change. List the missing ones for
|
||||||
|
explicit selection; adding one to a promotion needs a new beta.
|
||||||
|
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
|
||||||
|
authorization they already gave covers it.
|
||||||
|
|
||||||
|
For a promotion, every item defaults to defer.
|
||||||
|
|
||||||
|
## Reconcile main
|
||||||
|
|
||||||
|
After the release push, open a PR into `main` that brings in what shipped and keeps newer
|
||||||
|
development on `main`:
|
||||||
|
|
||||||
|
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
|
||||||
|
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
|
||||||
|
below any newer entries.
|
||||||
|
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
|
||||||
|
carries a newer version, plus the F-Droid changelog files a stable release generates.
|
||||||
|
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
|
||||||
|
`main` holds for changes that have not shipped stay out of it.
|
||||||
|
|
||||||
|
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
|
||||||
|
maintains no release track besides `main`.
|
||||||
|
|
||||||
## ACP catalog updates
|
## ACP catalog updates
|
||||||
|
|
||||||
@@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request:
|
|||||||
The release authorization covers the requested ACP commit. It ships in the same
|
The release authorization covers the requested ACP commit. It ships in the same
|
||||||
release push as the changelog and version commit.
|
release push as the changelog and version commit.
|
||||||
|
|
||||||
## Two paths
|
|
||||||
|
|
||||||
There are two supported release paths:
|
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
|
||||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
|
||||||
|
|
||||||
Paseo has one linear release track even though npm dist-tags are independent
|
|
||||||
pointers. The npm invariant is:
|
|
||||||
|
|
||||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
|
||||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
|
||||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
|
||||||
a beta is promoted or superseded by a direct stable release.
|
|
||||||
|
|
||||||
## Release version decision
|
## Release version decision
|
||||||
|
|
||||||
Every fresh release starts by classifying the full diff from the previous
|
Every fresh release starts by classifying the full diff from the previous
|
||||||
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
|
|||||||
|
|
||||||
Before running any stable release command:
|
Before running any stable release command:
|
||||||
|
|
||||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
|
||||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
@@ -145,9 +203,9 @@ npm run release:minor
|
|||||||
|
|
||||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||||
|
|
||||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
|
||||||
version for every published package. This changes dist-tags only; do not
|
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
|
||||||
republish the packages:
|
do not republish the packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||||
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
|
||||||
stable release complete.
|
active newer beta, before considering the stable release complete.
|
||||||
|
|
||||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||||
|
|
||||||
@@ -175,7 +233,7 @@ npm run version:all:patch
|
|||||||
npm run version:all:minor
|
npm run version:all:minor
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beta flow
|
## Beta flow
|
||||||
@@ -223,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
|
|||||||
|
|
||||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||||
|
|
||||||
### Instant-admit release (rollout_hours=0 from publish)
|
### Instant-admit release (rollout_hours=0)
|
||||||
|
|
||||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Cut and publish (default 36h ramp from tag push).
|
# 1. Cut and publish (36h ramp from tag push).
|
||||||
npm run release:patch
|
npm run release:patch
|
||||||
|
|
||||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
# 2. Wait for Desktop Release to publish the GitHub Release.
|
||||||
|
gh run watch <desktop-release-run-id> --exit-status
|
||||||
|
|
||||||
|
# 3. Flip the published manifests to instant admit.
|
||||||
gh workflow run desktop-rollout.yml \
|
gh workflow run desktop-rollout.yml \
|
||||||
-f tag=v0.1.64 \
|
-f tag=v0.1.64 \
|
||||||
-f rollout_hours=0
|
-f rollout_hours=0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
|
||||||
|
|
||||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||||
|
|
||||||
### Adjusting an already-published release
|
### Adjusting an already-published release
|
||||||
|
|
||||||
@@ -305,8 +366,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
|||||||
|
|
||||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||||
|
|
||||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
|
||||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||||
|
|
||||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||||
@@ -317,6 +378,17 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
|||||||
|
|
||||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||||
|
|
||||||
|
### Store screenshots
|
||||||
|
|
||||||
|
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||||
|
|
||||||
|
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||||
|
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
|
||||||
|
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
|
||||||
|
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||||
|
|
||||||
|
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||||
|
|
||||||
### Watching mobile builds from the terminal
|
### Watching mobile builds from the terminal
|
||||||
|
|
||||||
Use the EAS CLI from `packages/app/`:
|
Use the EAS CLI from `packages/app/`:
|
||||||
@@ -359,6 +431,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
|||||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||||
- `build_android` — Android store binary built
|
- `build_android` — Android store binary built
|
||||||
- `submit_android` — Android binary submitted to the Play Store
|
- `submit_android` — Android binary submitted to the Play Store
|
||||||
|
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||||
|
|
||||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||||
|
|
||||||
@@ -477,7 +550,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
|||||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the code you want built, with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
@@ -620,7 +693,7 @@ Rules:
|
|||||||
|
|
||||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||||
|
|
||||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
|
||||||
|
|
||||||
## Changelog ordering
|
## Changelog ordering
|
||||||
|
|
||||||
@@ -652,11 +725,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
|
|
||||||
### Beta release
|
### Beta release
|
||||||
|
|
||||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
||||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
||||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
||||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||||
@@ -667,22 +740,24 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||||
|
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|
||||||
### Stable release (or promotion)
|
### Stable release (or promotion)
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
||||||
|
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
|
||||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
||||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
||||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
|
||||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||||
@@ -696,4 +771,5 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||||
- [ ] EAS Android `build_android` completes for the same tag
|
- [ ] EAS Android `build_android` completes for the same tag
|
||||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||||
|
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
|
|||||||
|
|
||||||
```
|
```
|
||||||
pty (node-pty, forked worker process)
|
pty (node-pty, forked worker process)
|
||||||
→ headless xterm parse (worker, snapshot fidelity)
|
→ headless xterm parse (worker, ANSI replay fidelity)
|
||||||
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
|
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
|
||||||
→ process.send IPC → daemon main process
|
→ framed local socket → daemon main process
|
||||||
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
|
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
|
||||||
→ binary ws frame (2-byte header + raw bytes)
|
→ binary ws frame (2-byte header + raw bytes)
|
||||||
→ client decode (daemon-client.ts) → stream router → emulator runtime
|
→ client decode (daemon-client.ts) → stream router → emulator runtime
|
||||||
@@ -19,11 +19,15 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
|||||||
|
|
||||||
## Invariants (the easy-to-break ones)
|
## Invariants (the easy-to-break ones)
|
||||||
|
|
||||||
|
- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||||
|
- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations.
|
||||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||||
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||||
|
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
|
||||||
|
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
|
||||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
- **Snapshot catch-up is backpressure-gated.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer.
|
||||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||||
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Terminal rendering
|
||||||
|
|
||||||
|
Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/headless` buffer in JS and draw it with the `PaseoTerminal` native view in `packages/app/modules/paseo-terminal`: CoreText on iOS, `Canvas.drawTextRun` on Android. `src/terminal/native-renderer/terminal-frame.ts` turns the visible buffer into frames, and both native views take the same props.
|
||||||
|
|
||||||
|
## xterm owns the grid
|
||||||
|
|
||||||
|
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. 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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Marks stacked above or below a letter, Arabic letter tails and some fallback glyphs ink into the rows next to theirs. Neither view clips a row to its own bounds, because a row clip also cuts marks and Arabic dots. Rows draw top to bottom, so a later row's cell backgrounds cover the ink that reaches into it.
|
||||||
|
|
||||||
|
Android redraws every row on each frame, so overhanging ink cannot go stale. Its row `RenderNode`s are created with `setClipToBounds(false)`. The default would clip only unselected rows, because selected rows are drawn straight onto the view's canvas.
|
||||||
|
|
||||||
|
iOS repaints only the rows that changed. Each `TerminalRowLayout` records how far its glyphs ink above and below the row. A changed row invalidates its old and new ink extent, and `draw(_:)` redraws every row whose ink extent meets the dirty rect. Invalidating only the row's own rect left an erased row's marks in the row next to it, erased a neighbour's marks when a row was rewritten, and showed or hid overhang depending on which rows changed together.
|
||||||
|
|
||||||
|
## Mixed-direction text
|
||||||
|
|
||||||
|
Rows draw left to right in logical order on both platforms. Terminal bidi escape modes are not implemented. On iOS the row needs both the `kCTWritingDirection` override and an LTR paragraph base direction. Without the base direction, bidi rule L1 moves trailing spaces ahead of a row that starts with Arabic or Hebrew, and the whole row shifts.
|
||||||
|
|
||||||
|
Arabic still gets the contextual form its logical neighbours select, but in left-to-right order, so letters join on the wrong side. Correct visual Arabic needs implicit bidi per row: reorder RTL runs for display and map cursor, selection and hit-testing through the visual-to-logical index. That changes which column a character appears in, so it has to be decided as product behaviour first. Shaping alone cannot fix it.
|
||||||
|
|
||||||
|
## Render regression
|
||||||
|
|
||||||
|
The scripts live in `packages/app/e2e/mobile/terminal-rendering`. Run them from `packages/app` on the daemon's host, starting from an idle, focused terminal with an empty prompt.
|
||||||
|
|
||||||
|
`render.mjs` prints a mixed-script fixture, screenshots it with agent-device, and checks the geometry with `check.mjs`. The check rebuilds the expected buffer with `@xterm/headless` and measures the grid from a ruler row. It checks that:
|
||||||
|
|
||||||
|
- every grapheme cluster has ink inside its own columns, and blank cells stay blank;
|
||||||
|
- on the accent rows, every cell is drawn in its own colour, including cells inside one cluster;
|
||||||
|
- the cursor is drawn after the text;
|
||||||
|
- every row is exposed as `terminal-row-<n>`;
|
||||||
|
- selecting one cell of a conjunct highlights only that cell.
|
||||||
|
|
||||||
|
On iOS the screenshot is taken at 3 pixels per point (`PASEO_TERMINAL_RENDERING_PIXEL_DENSITY`), because the simulator's default is one pixel per point and a glyph is then about 12 pixels. Focus the terminal before a run: with the simulator's hardware keyboard connected the key bar shows without a focused input, and `type` fails. In a debug build, the dev client's floating gear button covers the right end of the first row, so ruler cells there fail.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
|
||||||
|
PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Geometry passes when a cell holds the wrong glyph. `identity.mjs` judges each cluster of a `render.mjs` run against pango's rendering of the same characters in the device's fonts. The reference is cut at the edges of the cluster's columns, placed as the renderer places it, because glyphs such as Devanagari letters ink past their advance and the device cuts them there too. A shape passes when its coverage correlates with its own reference at 0.6 or more, and no other cluster of the corpus matches it better. Box and block characters are judged against their Unicode definition. Shades are judged by even density over a 4×4 grid, because the device and pango fall back to different shade fonts. Clusters that pango draws without ink are reported as unjudged instead: flags on both platforms and Apple Color Emoji. The `rect` and `swap` controls fill every cluster with white, or judge each one against another cluster. Both must fail every cluster, which shows the oracle refuses ink that is present but wrong. An Arabic letter is compared with all four of its positional forms, so the oracle shows the right letter is drawn, not that it has the right form. Ink that a neighbouring row draws into a cluster's cells counts as that cluster's ink. On iOS the tail of `م` in the Arabic row reaches one pixel into the `न` below it, so `न` fails although both rows are drawn correctly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FONTCONFIG_FILE=fonts.conf node e2e/mobile/terminal-rendering/identity.mjs <run dir> [none|rect|swap]
|
||||||
|
```
|
||||||
|
|
||||||
|
`fonts.conf` points fontconfig at the device's font files and lists, for `monospace`, the terminal font followed by the device's fallbacks for Hangul, Arabic, Devanagari, CJK, emoji and symbols:
|
||||||
|
|
||||||
|
- **Android:** pull the fonts from `/system/fonts` with `adb pull`. The terminal font is Droid Sans Mono.
|
||||||
|
- **iOS:** copy the simulator runtime's fonts from `RuntimeRoot/System/Library/Fonts`. The terminal font is Menlo. PingFang stores its outlines in Apple's `hvgl` table, which FreeType cannot draw, so the CJK references come from Apple SD Gothic Neo instead. `語` then scores below the threshold although the device draws it correctly.
|
||||||
|
|
||||||
|
`overhang.mjs` prints rows whose marks reach the rows next to them. In one update it writes into two rows that hold a neighbour's marks and erases two rows whose marks reach a blank row. The marks must be drawn before the update, stay drawn in the written rows, and leave no ink behind the erased rows. Run it like `render.mjs`.
|
||||||
|
|
||||||
|
`reattach.mjs` checks the restore size. It changes the PTY size by opening the keyboard (`RESIZE=keyboard`) or folding an Android foldable (`RESIZE=fold`), relaunches the app, and compares the client's rows with `paseo terminal capture`. A trial where no terminal rows appear, for example after a launch crash, counts as never attached and fails the run.
|
||||||
+23
-6
@@ -15,15 +15,20 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
|||||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||||
|
|
||||||
|
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||||
|
|
||||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||||
|
|
||||||
## Record, socket and secret
|
## Record, socket and secret
|
||||||
|
|
||||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
|
||||||
|
|
||||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
|
||||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
|
||||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
|
||||||
|
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
|
||||||
|
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
|
||||||
|
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
|
||||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||||
|
|
||||||
@@ -38,12 +43,24 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
|
|||||||
| two or more older | shuts it down; its terminals close (logged) |
|
| two or more older | shuts it down; its terminals close (logged) |
|
||||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||||
|
|
||||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||||
|
|
||||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||||
|
|
||||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||||
|
|
||||||
|
## Display replay
|
||||||
|
|
||||||
|
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
|
||||||
|
|
||||||
|
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
|
||||||
|
|
||||||
|
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
|
||||||
|
|
||||||
|
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
|
||||||
|
|
||||||
|
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
|
||||||
|
|
||||||
## Running from its own copy
|
## Running from its own copy
|
||||||
|
|
||||||
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
||||||
|
|||||||
+19
-7
@@ -10,16 +10,27 @@ then replaces the previous tool state or merges into the current text item. Inte
|
|||||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||||
and rebuilds the projection when an agent resumes.
|
and rebuilds the projection when an agent resumes.
|
||||||
|
|
||||||
|
Pagination bounds each read, not total resident history. Active and idle agents retain their
|
||||||
|
projected transcript; archive releases it and later history reads rebuild it from the provider.
|
||||||
|
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
|
||||||
|
must be released after their deltas have been consumed.
|
||||||
|
|
||||||
The invariants are:
|
The invariants are:
|
||||||
|
|
||||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||||
> through backward pagination.
|
> through backward pagination.
|
||||||
|
|
||||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
Every item is bounded before it enters either delivery path, because a timeline page is one
|
||||||
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
|
||||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
|
||||||
tool payload.
|
`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up
|
||||||
|
to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker
|
||||||
|
element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The
|
||||||
|
budget does not bound wide objects or multi-byte text. The same
|
||||||
|
bounded item is used for runtime timeline rows and live stream events. Provider history hydration
|
||||||
|
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
|
||||||
|
again to assistant and reasoning rows the projection merges from many chunks.
|
||||||
|
|
||||||
## Presence is not delivery
|
## Presence is not delivery
|
||||||
|
|
||||||
@@ -84,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
|||||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||||
|
|
||||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||||
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
|
||||||
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
|
||||||
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
|
||||||
|
updated after the checkpoint does not block it. Forking the current context remains available.
|
||||||
|
|
||||||
## Resume behavior
|
## Resume behavior
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
|
|||||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||||
Electron running the old code.
|
Electron running the old code.
|
||||||
|
|
||||||
|
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
|
||||||
|
owned by Unistyles' change listeners. Chromium defers their events while a window
|
||||||
|
is hidden; reading `matches` on those same lists can consume the pending change.
|
||||||
|
CSS variables then follow the OS theme while `withUnistyles` props retain the old
|
||||||
|
colors. Keep this patch until upstream reads the scheme through separate lists.
|
||||||
|
The desktop browser E2E checks both transition directions in an isolated Electron
|
||||||
|
process, because Playwright's focus emulation prevents real hidden-page behavior.
|
||||||
|
|
||||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||||
`patches/` is inert unless that script knows which installed package activates
|
`patches/` is inert unless that script knows which installed package activates
|
||||||
it.
|
it.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Added
|
||||||
|
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||||
|
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Added
|
||||||
|
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||||
|
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Added
|
||||||
|
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||||
|
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Added
|
||||||
|
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||||
|
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||||
|
|
||||||
|
Improved
|
||||||
|
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-1.png
|
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-2.png
|
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-3.png
|
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||||
@@ -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-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
|
||||||
|
|||||||
Generated
+791
-915
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.15.3",
|
||||||
"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",
|
||||||
@@ -148,14 +149,21 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/view": "6.43.6",
|
"@codemirror/view": "6.43.6",
|
||||||
|
"@electron/get@^3.0.0": {
|
||||||
|
"semver@^6": "6.3.1"
|
||||||
|
},
|
||||||
"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",
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
"react-native-reanimated": "4.5.5",
|
"react-native-reanimated": "4.5.5",
|
||||||
"react-native-worklets": "0.10.4"
|
"react-native-worklets": "0.10.4",
|
||||||
|
"tiny-async-pool": {
|
||||||
|
"semver@^5": "5.7.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ jobs:
|
|||||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||||
profile: production
|
profile: production
|
||||||
|
|
||||||
|
upload_android_screenshots:
|
||||||
|
name: Upload Google Play screenshots
|
||||||
|
needs: [build_android, submit_android]
|
||||||
|
environment: production
|
||||||
|
runs_on: macos-medium
|
||||||
|
steps:
|
||||||
|
- uses: eas/checkout
|
||||||
|
- name: Install fastlane
|
||||||
|
run: bundle install
|
||||||
|
- name: Upload screenshots
|
||||||
|
run: |
|
||||||
|
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||||
|
bundle exec fastlane android upload_screenshots
|
||||||
|
|
||||||
submit_ios_for_review:
|
submit_ios_for_review:
|
||||||
name: Submit iOS for App Store review
|
name: Submit iOS for App Store review
|
||||||
needs: [build_ios, distribute_ios]
|
needs: [build_ios, distribute_ios]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Upload Google Play screenshots
|
||||||
|
|
||||||
|
# Standalone re-trigger for the release workflow's Google Play screenshot step.
|
||||||
|
# Replaces the en-US phone and tablet screenshots with the committed renders,
|
||||||
|
# without rebuilding or resubmitting the app.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
android_version_code:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload_android_screenshots:
|
||||||
|
name: Upload Google Play screenshots
|
||||||
|
environment: production
|
||||||
|
runs_on: macos-medium
|
||||||
|
steps:
|
||||||
|
- uses: eas/checkout
|
||||||
|
- name: Install fastlane
|
||||||
|
run: bundle install
|
||||||
|
- name: Upload screenshots
|
||||||
|
run: |
|
||||||
|
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
|
||||||
|
bundle exec fastlane android upload_screenshots
|
||||||
@@ -102,7 +102,7 @@ export default {
|
|||||||
name: variant.name,
|
name: variant.name,
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: nativeReleaseVersion.appVersion,
|
version: nativeReleaseVersion.appVersion,
|
||||||
orientation: "portrait",
|
orientation: "default",
|
||||||
icon: "./assets/images/icon.png",
|
icon: "./assets/images/icon.png",
|
||||||
scheme: "paseo",
|
scheme: "paseo",
|
||||||
userInterfaceStyle: "automatic",
|
userInterfaceStyle: "automatic",
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -59,3 +59,56 @@ describe("Android app config", () => {
|
|||||||
30_000,
|
30_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows system rotation on Android", async () => {
|
||||||
|
const manifest = await resolveAndroidManifest();
|
||||||
|
const activity = manifest.application[0].activity.find(
|
||||||
|
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
||||||
|
);
|
||||||
|
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("allows all orientations on iPhone and iPad", async () => {
|
||||||
|
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||||
|
const { tmpdir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
|
||||||
|
try {
|
||||||
|
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
|
||||||
|
await compileModsAsync(exp, {
|
||||||
|
projectRoot: root,
|
||||||
|
introspect: true,
|
||||||
|
platforms: ["ios"],
|
||||||
|
assertMissingModProviders: false,
|
||||||
|
});
|
||||||
|
const plist = exp._internal.modResults.ios.infoPlist;
|
||||||
|
const orientations = [
|
||||||
|
"UIInterfaceOrientationPortrait",
|
||||||
|
"UIInterfaceOrientationPortraitUpsideDown",
|
||||||
|
"UIInterfaceOrientationLandscapeLeft",
|
||||||
|
"UIInterfaceOrientationLandscapeRight",
|
||||||
|
];
|
||||||
|
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
|
||||||
|
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 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 }) => {
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
|
|||||||
lastTop: initialRow.getBoundingClientRect().top,
|
lastTop: initialRow.getBoundingClientRect().top,
|
||||||
largestDownwardShift: 0,
|
largestDownwardShift: 0,
|
||||||
sawMissing: false,
|
sawMissing: false,
|
||||||
|
sawHidden: false,
|
||||||
};
|
};
|
||||||
const windowState = window as unknown as Record<string, unknown>;
|
const windowState = window as unknown as Record<string, unknown>;
|
||||||
windowState.__messageSubmissionTimelineRowStability = state;
|
windowState.__messageSubmissionTimelineRowStability = state;
|
||||||
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
|
|||||||
requestAnimationFrame(checkFrame);
|
requestAnimationFrame(checkFrame);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||||
|
state.sawHidden = true;
|
||||||
|
}
|
||||||
const top = initialRow.getBoundingClientRect().top;
|
const top = initialRow.getBoundingClientRect().top;
|
||||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||||
state.lastTop = top;
|
state.lastTop = top;
|
||||||
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
|
|||||||
const result = await page.evaluate(() => {
|
const result = await page.evaluate(() => {
|
||||||
const windowState = window as unknown as Record<string, unknown>;
|
const windowState = window as unknown as Record<string, unknown>;
|
||||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
| {
|
||||||
|
active: boolean;
|
||||||
|
largestDownwardShift: number;
|
||||||
|
sawMissing: boolean;
|
||||||
|
sawHidden: boolean;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||||
state.active = false;
|
state.active = false;
|
||||||
delete windowState.__messageSubmissionTimelineRowStability;
|
delete windowState.__messageSubmissionTimelineRowStability;
|
||||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
return {
|
||||||
|
largestDownwardShift: state.largestDownwardShift,
|
||||||
|
sawMissing: state.sawMissing,
|
||||||
|
sawHidden: state.sawHidden,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
expect(result.sawMissing).toBe(false);
|
expect(result.sawMissing).toBe(false);
|
||||||
|
expect(result.sawHidden).toBe(false);
|
||||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
|
|||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
const toasts = await recordPanelToasts(page);
|
const toasts = await recordPanelToasts(page);
|
||||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||||
|
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||||
|
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||||
|
page,
|
||||||
|
pending.userMessage,
|
||||||
|
);
|
||||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||||
|
await finishTimelineRowStabilityCheck();
|
||||||
// A chat this client just created is current by construction; it is never out of date.
|
// A chat this client just created is current by construction; it is never out of date.
|
||||||
await toasts.expectNeverShown("agent-updating-toast");
|
await toasts.expectNeverShown("agent-updating-toast");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => {
|
|||||||
const gate = await installDaemonWebSocketGate(page);
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||||
try {
|
try {
|
||||||
|
gate.holdNextClientRequest("clear_agent_attention");
|
||||||
await openAgentTimeline(page, agent);
|
await openAgentTimeline(page, agent);
|
||||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
await gate.waitForHeldClientRequest();
|
||||||
|
|
||||||
gate.failTimelineResponses(agent.agentId);
|
gate.failTimelineResponses(agent.agentId);
|
||||||
await disconnectViewedTimeline(page, gate);
|
await disconnectViewedTimeline(page, gate);
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect, test as baseTest } from "../support/fixtures";
|
||||||
|
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
|
import {
|
||||||
|
openAgentRoute,
|
||||||
|
seedMockAgentWorkspace,
|
||||||
|
type MockAgentWorkspace,
|
||||||
|
} from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
|
||||||
|
// within a few milliseconds of each other. These tests hold all three, let the running
|
||||||
|
// turn paint, then release them together and sample every painted frame.
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnEndFrame {
|
||||||
|
at: number;
|
||||||
|
running: boolean;
|
||||||
|
footer: Box | null;
|
||||||
|
userRow: Box | null;
|
||||||
|
replyVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
|
||||||
|
agent: async ({ page: _page }, provide) => {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "turn-end-",
|
||||||
|
title: "Turn end",
|
||||||
|
initialPrompt: "emit 4 tool agent stream updates",
|
||||||
|
});
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||||
|
await provide(agent);
|
||||||
|
await agent.cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function recordTurnEndFrames(
|
||||||
|
page: Page,
|
||||||
|
input: { prompt: string; reply: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(({ prompt, reply }) => {
|
||||||
|
const state = { active: true, frames: [] as TurnEndFrame[] };
|
||||||
|
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
|
||||||
|
const boxOf = (element: Element | null | undefined): Box | null => {
|
||||||
|
if (!element || !isVisible(element)) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
|
||||||
|
};
|
||||||
|
const sample = () => {
|
||||||
|
const viewport = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||||
|
).find((candidate) => isVisible(candidate));
|
||||||
|
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||||
|
.map(boxOf)
|
||||||
|
.filter((box): box is Box => box !== null)
|
||||||
|
.sort((left, right) => left.top - right.top);
|
||||||
|
const userRow = Array.from(
|
||||||
|
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||||
|
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||||
|
const replyRow =
|
||||||
|
reply === null
|
||||||
|
? undefined
|
||||||
|
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||||
|
(candidate) => candidate.textContent?.includes(reply),
|
||||||
|
);
|
||||||
|
state.frames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||||
|
footer: footers.at(-1) ?? null,
|
||||||
|
userRow: boxOf(userRow),
|
||||||
|
replyVisible: boxOf(replyRow) !== null,
|
||||||
|
});
|
||||||
|
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
|
||||||
|
};
|
||||||
|
window.__turnEndFrames = state;
|
||||||
|
requestAnimationFrame(() => setTimeout(sample, 0));
|
||||||
|
}, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
|
||||||
|
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
|
||||||
|
.toBeGreaterThanOrEqual(until);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const state = window.__turnEndFrames;
|
||||||
|
if (!state) throw new Error("Turn end frames were not recorded");
|
||||||
|
state.active = false;
|
||||||
|
return state.frames;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordTurnEnd(
|
||||||
|
page: Page,
|
||||||
|
agent: MockAgentWorkspace,
|
||||||
|
input: { prompt: string; reply: string | null },
|
||||||
|
): Promise<TurnEndFrame[]> {
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||||
|
await expectAgentIdle(page);
|
||||||
|
|
||||||
|
if (input.reply === null) {
|
||||||
|
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||||
|
} else {
|
||||||
|
gate.holdNextAgentStreamItem("assistant_message");
|
||||||
|
}
|
||||||
|
gate.holdNextAgentStreamEvent("turn_completed");
|
||||||
|
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
|
||||||
|
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||||
|
gate.setServerMessageSuppressed("agent_update", true);
|
||||||
|
await recordTurnEndFrames(page, input);
|
||||||
|
await submitMessage(page, input.prompt);
|
||||||
|
await Promise.all([
|
||||||
|
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
|
||||||
|
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||||
|
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||||
|
await recordPaintsFor(page, 200);
|
||||||
|
|
||||||
|
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
|
||||||
|
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||||
|
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||||
|
gate.setServerMessageSuppressed("agent_update", false);
|
||||||
|
await expectAgentIdle(page);
|
||||||
|
await recordPaintsFor(page, 300);
|
||||||
|
return stopRecording(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeFrames(frames: TurnEndFrame[]): string {
|
||||||
|
return frames
|
||||||
|
.map(
|
||||||
|
(frame, index) =>
|
||||||
|
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||||
|
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||||
|
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
|
||||||
|
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||||
|
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
|
||||||
|
const record = `frames:\n${describeFrames(frames)}`;
|
||||||
|
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(firstComplete, record).toBeGreaterThan(firstRunning);
|
||||||
|
|
||||||
|
const transition = frames.slice(firstRunning);
|
||||||
|
const runningHeight = transition[0].footer?.height;
|
||||||
|
expect(
|
||||||
|
transition.map((frame) => frame.footer?.height ?? null),
|
||||||
|
`the footer must stay painted at one height\n${record}`,
|
||||||
|
).toEqual(transition.map(() => runningHeight));
|
||||||
|
if (input.reply !== null) {
|
||||||
|
expect(
|
||||||
|
frames.slice(firstComplete).every((frame) => frame.replyVisible),
|
||||||
|
`a completed turn must show its reply in the same frame\n${record}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
// Tool rows land while the turn runs; only the turn end itself must be one step.
|
||||||
|
const ending = frames.slice(firstComplete - 1);
|
||||||
|
const promptMoves = ending
|
||||||
|
.slice(1)
|
||||||
|
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
|
||||||
|
expect(
|
||||||
|
promptMoves.length,
|
||||||
|
`the prompt must move at most once as the turn ends\n${record}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Turn end", () => {
|
||||||
|
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||||
|
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||||
|
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
|
||||||
|
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
|
||||||
|
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
|
||||||
|
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
|
||||||
|
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
|||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("on a touch-capable desktop browser", () => {
|
||||||
|
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
|
||||||
|
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
|
||||||
|
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "assistant-selection-drag-",
|
||||||
|
title: "Assistant selection drag",
|
||||||
|
initialPrompt: "Render three paragraphs.",
|
||||||
|
featureValues: {
|
||||||
|
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.client.waitForAgentUpsert(
|
||||||
|
agent.agentId,
|
||||||
|
(snapshot) => snapshot.status === "idle",
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
const blocks = assistantMessageBlocks(page);
|
||||||
|
const first = await blocks.getByText("First paragraph.").boundingBox();
|
||||||
|
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
||||||
|
if (!first || !third) {
|
||||||
|
throw new Error("Expected rendered paragraphs");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||||
|
expect(selected).toContain("First paragraph.");
|
||||||
|
expect(selected).toContain("Third paragraph.");
|
||||||
|
} 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,4 +1,7 @@
|
|||||||
|
import { writeFile, unlink } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
expectNoBranchSwitcherInWorkspaceHeader,
|
expectNoBranchSwitcherInWorkspaceHeader,
|
||||||
@@ -14,6 +17,88 @@ import {
|
|||||||
waitForSidebarHydration,
|
waitForSidebarHydration,
|
||||||
} from "../support/helpers/workspace-ui";
|
} from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
|
// Hold the real daemon response until the test has observed the pending UI.
|
||||||
|
async function holdBranchSuggestions(page: Page) {
|
||||||
|
let requestCount = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
let gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||||
|
const server = browser.connectToServer();
|
||||||
|
browser.onMessage((message) => {
|
||||||
|
const envelope = JSON.parse(message.toString());
|
||||||
|
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||||
|
requestCount++;
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
server.onMessage(async (message) => {
|
||||||
|
const envelope = JSON.parse(message.toString());
|
||||||
|
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
|
||||||
|
await gate;
|
||||||
|
}
|
||||||
|
browser.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
release: () => release(),
|
||||||
|
holdNextResponse: () => {
|
||||||
|
gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestCount: () => requestCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
||||||
|
const picker = page.getByTestId("combobox-desktop-container");
|
||||||
|
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
||||||
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||||
|
release();
|
||||||
|
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(picker).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectFailedRefreshRecovery(
|
||||||
|
page: Page,
|
||||||
|
repoPath: string,
|
||||||
|
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
||||||
|
) {
|
||||||
|
// Expire the actual query freshness window before reopening the cached picker.
|
||||||
|
const started = Date.now();
|
||||||
|
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
||||||
|
const picker = page.getByTestId("combobox-desktop-container");
|
||||||
|
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
||||||
|
await writeFile(packedRefs, "invalid packed refs\n");
|
||||||
|
try {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
||||||
|
.click();
|
||||||
|
await expect(picker.getByRole("alert")).toHaveText(
|
||||||
|
"Could not load branches. Reopen the picker to try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await unlink(packedRefs);
|
||||||
|
}
|
||||||
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||||
|
responses.holdNextResponse();
|
||||||
|
const beforeRetry = responses.requestCount();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
||||||
|
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
||||||
|
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
responses.release();
|
||||||
|
await expect(picker.getByRole("alert")).toHaveCount(0);
|
||||||
|
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expectWorkspaceBranch(page, "dev");
|
||||||
|
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
||||||
|
}
|
||||||
|
|
||||||
async function renameWorkspaceViaSidebar(
|
async function renameWorkspaceViaSidebar(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { workspaceId: string; title: string },
|
input: { workspaceId: string; title: string },
|
||||||
@@ -59,6 +144,7 @@ test.describe("Branch switcher", () => {
|
|||||||
try {
|
try {
|
||||||
expect(workspace.workspaceName).toBe("main");
|
expect(workspace.workspaceName).toBe("main");
|
||||||
|
|
||||||
|
const releaseBranches = await holdBranchSuggestions(page);
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||||
@@ -82,8 +168,10 @@ test.describe("Branch switcher", () => {
|
|||||||
// and switching it checks out the real branch on disk.
|
// and switching it checks out the real branch on disk.
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expectWorkspaceBranch(page, "main");
|
await expectWorkspaceBranch(page, "main");
|
||||||
|
await expectInitialBranchLoading(page, releaseBranches.release);
|
||||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||||
await expectWorkspaceBranch(page, "dev");
|
await expectWorkspaceBranch(page, "dev");
|
||||||
|
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
||||||
|
|
||||||
// The custom title is unaffected by the branch switch.
|
// The custom title is unaffected by the branch switch.
|
||||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||||
|
|||||||
@@ -323,6 +323,119 @@ test("All changes combines committed and pending edits and preserves an unfinish
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const workspace = await createWorktreeWithCommittedDiff();
|
||||||
|
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||||
|
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
});
|
||||||
|
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
const picker = tree.getByTestId("workspace-base-branch-trigger");
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.click();
|
||||||
|
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
|
||||||
|
await page.getByRole("button", { name: "release", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("release");
|
||||||
|
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||||
|
await picker.click();
|
||||||
|
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("upstream/release");
|
||||||
|
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
|
||||||
|
await page.reload();
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(picker).toContainText("upstream/release");
|
||||||
|
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
|
||||||
|
const workspace = await createWorktreeWithCommittedDiff();
|
||||||
|
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||||
|
await openCompactChanges(page, workspace);
|
||||||
|
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
|
||||||
|
await picker.click();
|
||||||
|
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
|
||||||
|
const release = page.getByRole("button", { name: "release", exact: true });
|
||||||
|
await expect(release).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
|
||||||
|
await release.click();
|
||||||
|
await expect(picker).toContainText("release");
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
|
||||||
|
const workspace = await createWorktreeWithCommittedDiff();
|
||||||
|
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
const picker = page
|
||||||
|
.getByTestId("workspace-base-branch-trigger")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await picker.click();
|
||||||
|
const option = page.getByRole("button", { name: "release", exact: true });
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
|
||||||
|
await option.click();
|
||||||
|
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
|
||||||
|
"Base branch not found",
|
||||||
|
);
|
||||||
|
await expect(picker).toContainText("main");
|
||||||
|
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||||
|
await option.click();
|
||||||
|
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
|
||||||
|
await expect(picker).toContainText("release");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes shows the base as text on a host without the base-branch capability", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let baseRequests = 0;
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||||
|
const server = browser.connectToServer();
|
||||||
|
browser.onMessage((frame) => {
|
||||||
|
if (typeof frame === "string") {
|
||||||
|
const envelope = JSON.parse(frame) as { message?: { type?: string } };
|
||||||
|
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
|
||||||
|
}
|
||||||
|
server.send(frame);
|
||||||
|
});
|
||||||
|
server.onMessage((frame) => {
|
||||||
|
if (typeof frame === "string") {
|
||||||
|
const envelope = JSON.parse(frame) as {
|
||||||
|
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
|
||||||
|
};
|
||||||
|
const payload = envelope.message?.payload;
|
||||||
|
if (payload?.status === "server_info" && payload.features) {
|
||||||
|
delete payload.features.workspaceBaseBranch;
|
||||||
|
browser.send(JSON.stringify(envelope));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.send(frame);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const workspace = await createWorktreeWithCommittedDiff();
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||||
|
expect(baseRequests).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithCommittedDiff();
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
|
||||||
|
await expect(header).toContainText("feature");
|
||||||
|
await expect(header).not.toContainText("main");
|
||||||
|
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
@@ -928,11 +1041,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
|||||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||||
}
|
}
|
||||||
expect(actionsBox.width).toBe(48);
|
expect(actionsBox.width).toBe(48);
|
||||||
expect(actionsBox.height).toBe(28);
|
expect(actionsBox.height).toBe(32);
|
||||||
expect(optionsBox.width).toBe(32);
|
expect(optionsBox.width).toBe(44);
|
||||||
expect(optionsBox.height).toBe(32);
|
expect(optionsBox.height).toBe(44);
|
||||||
expect(glyphBox.width).toBe(18);
|
expect(glyphBox.width).toBe(20);
|
||||||
expect(glyphBox.height).toBe(18);
|
expect(glyphBox.height).toBe(20);
|
||||||
|
|
||||||
await expect(actions).not.toContainText("Commit");
|
await expect(actions).not.toContainText("Commit");
|
||||||
await expect(actions.locator("svg")).toHaveCount(2);
|
await expect(actions.locator("svg")).toHaveCount(2);
|
||||||
@@ -1657,6 +1770,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
|
|||||||
}).toPass({ timeout: 15_000 });
|
}).toPass({ timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||||
|
const repo = await createTempGitRepo("changes-base-worktree-", {
|
||||||
|
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||||
|
});
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
cleanupTasks.push({
|
||||||
|
run: async () => {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
|
||||||
|
});
|
||||||
|
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
|
||||||
|
const cwd = created.workspace.workspaceDirectory;
|
||||||
|
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
|
||||||
|
execFileSync("git", ["add", "committed-only.ts"], { cwd });
|
||||||
|
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
|
||||||
|
return { id: created.workspace.id, repoPath: cwd };
|
||||||
|
}
|
||||||
|
|
||||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||||
const repo = await createTempGitRepo("changes-committed-", {
|
const repo = await createTempGitRepo("changes-committed-", {
|
||||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
holdDirectorySuggestionResponses,
|
holdDirectorySuggestionResponses,
|
||||||
expectOneLineTruncatedFileResult,
|
expectOneLineTruncatedFileResult,
|
||||||
expectStableCommandCenterLayout,
|
expectStableCommandCenterLayout,
|
||||||
|
expectLayoutObservationToCatchPanelShift,
|
||||||
failDirectorySuggestionRequests,
|
failDirectorySuggestionRequests,
|
||||||
startCommandCenterLayoutObservation,
|
startCommandCenterLayoutObservation,
|
||||||
|
shiftOutsideCommandCenter,
|
||||||
} from "../support/helpers/command-center-file-search";
|
} from "../support/helpers/command-center-file-search";
|
||||||
import {
|
import {
|
||||||
scrollCommandCenterAwayFromSelection,
|
scrollCommandCenterAwayFromSelection,
|
||||||
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
|
|||||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||||
|
await shiftOutsideCommandCenter(page);
|
||||||
await expectStableCommandCenterLayout(page);
|
await expectStableCommandCenterLayout(page);
|
||||||
|
await expectLayoutObservationToCatchPanelShift(page);
|
||||||
|
|
||||||
await row.click();
|
await row.click();
|
||||||
await expectFileTabOpen(page, FILE_PATH);
|
await expectFileTabOpen(page, FILE_PATH);
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
import type { Page, TestInfo } from "@playwright/test";
|
import type { Page, TestInfo } from "@playwright/test";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
|
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
|
|||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
|
||||||
|
await openRunningScriptPage(page, "lint");
|
||||||
|
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
|
||||||
|
try {
|
||||||
|
await menuRow(page, "Restart").click();
|
||||||
|
await page.setViewportSize({ width: 1280, height: 844 });
|
||||||
|
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
resumeWorker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectScriptTerminalReplaced(
|
||||||
|
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
|
||||||
|
previousId: string,
|
||||||
|
) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const after = await workspace.client.listTerminals(undefined, undefined, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
});
|
||||||
|
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pending script restart survives removal of the compact header", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const workspace = await openWorkspaceWithScripts(page);
|
||||||
|
try {
|
||||||
|
await runScriptFromScriptsPage(page, "lint");
|
||||||
|
const before = await workspace.client.listTerminals(undefined, undefined, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
});
|
||||||
|
expect(before.terminals).toHaveLength(1);
|
||||||
|
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
|
||||||
|
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -101,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
|
|||||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installListCommandsStub(page: Page): Promise<void> {
|
interface ListCommandsReply {
|
||||||
|
commands: readonly { name: string; description: string; argumentHint: string }[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
|
||||||
|
* the response until it settles, standing in for a slow host.
|
||||||
|
*/
|
||||||
|
async function installListCommandsStub(
|
||||||
|
page: Page,
|
||||||
|
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
|
||||||
|
): Promise<void> {
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
@@ -126,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
const payload = parsed.message?.payload;
|
||||||
if (
|
if (
|
||||||
parsed.type === "session" &&
|
parsed.type === "session" &&
|
||||||
parsed.message?.type === "list_commands_response" &&
|
parsed.message?.type === "list_commands_response" &&
|
||||||
parsed.message.payload
|
payload
|
||||||
) {
|
) {
|
||||||
parsed.message.payload.commands = TEST_COMMANDS;
|
const send = (result: ListCommandsReply) => {
|
||||||
parsed.message.payload.error = null;
|
payload.commands = result.commands;
|
||||||
|
payload.error = result.error;
|
||||||
ws.send(JSON.stringify(parsed));
|
ws.send(JSON.stringify(parsed));
|
||||||
|
};
|
||||||
|
const result = reply();
|
||||||
|
if (result instanceof Promise) void result.then(send);
|
||||||
|
else send(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -145,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHeldReply(): {
|
||||||
|
reply: () => Promise<ListCommandsReply>;
|
||||||
|
release: () => void;
|
||||||
|
} {
|
||||||
|
let release = () => {};
|
||||||
|
const held = new Promise<ListCommandsReply>((resolve) => {
|
||||||
|
release = () => resolve(TEST_COMMANDS_REPLY);
|
||||||
|
});
|
||||||
|
return { reply: () => held, release };
|
||||||
|
}
|
||||||
|
|
||||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||||
@@ -365,6 +400,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,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -491,16 +576,17 @@ test.describe("Composer autocomplete", () => {
|
|||||||
const input = composerLocator(page);
|
const input = composerLocator(page);
|
||||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
|
||||||
await input.fill("/he");
|
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
|
||||||
|
await input.fill("/hea");
|
||||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
const beforeDelete = await visiblePopoverBox(page);
|
const beforeDelete = await visiblePopoverBox(page);
|
||||||
|
|
||||||
await input.press("Backspace");
|
await input.press("Backspace");
|
||||||
await expect(input).toHaveValue("/h");
|
await expect(input).toHaveValue("/he");
|
||||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
const afterDelete = await visiblePopoverBox(page);
|
const afterDelete = await visiblePopoverBox(page);
|
||||||
@@ -679,6 +765,116 @@ test.describe("Composer autocomplete", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
|
||||||
|
await installListCommandsStub(page);
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-stale-choice-",
|
||||||
|
title: "Autocomplete stale choice",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
await input.fill("/");
|
||||||
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
|
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Down wraps from the default bottom row to the top: /clear, then /exit.
|
||||||
|
await input.press("ArrowDown");
|
||||||
|
await input.press("ArrowDown");
|
||||||
|
await input.press("h");
|
||||||
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||||
|
await input.press("Backspace");
|
||||||
|
await expect(input).toHaveValue("/");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
await expect(input).toHaveValue("/tdd ");
|
||||||
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
|
||||||
|
const host = createHeldReply();
|
||||||
|
await installListCommandsStub(page, host.reply);
|
||||||
|
const agent = await openReadyMockAgent(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
await input.fill("/");
|
||||||
|
|
||||||
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const loading = await visiblePopoverBox(page);
|
||||||
|
|
||||||
|
host.release();
|
||||||
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
|
||||||
|
const results = await visiblePopoverBox(page);
|
||||||
|
|
||||||
|
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
|
||||||
|
commands: [],
|
||||||
|
error: "Timed out waiting for the agent session",
|
||||||
|
});
|
||||||
|
await installListCommandsStub(page, () => reply());
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-failing-host-",
|
||||||
|
title: "Autocomplete failing host",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
await input.fill("/");
|
||||||
|
|
||||||
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
|
const errorRow = popover.getByTestId("composer-autocomplete-error");
|
||||||
|
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await input.press("Enter");
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
|
|
||||||
|
await input.fill("/");
|
||||||
|
await expect(errorRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const host = createHeldReply();
|
||||||
|
reply = host.reply;
|
||||||
|
await errorRow.getByRole("button", { name: "Retry" }).click();
|
||||||
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
|
||||||
|
|
||||||
|
host.release();
|
||||||
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("compact sidebar layering", () => {
|
test.describe("compact sidebar layering", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Locator } from "@playwright/test";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
|
async function box(locator: Locator) {
|
||||||
|
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||||
|
const rect = await locator.boundingBox();
|
||||||
|
expect(rect).not.toBeNull();
|
||||||
|
return { top: rect!.y, bottom: rect!.y + rect!.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Composer bottom spacing", () => {
|
||||||
|
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "composer-bottom-spacing-",
|
||||||
|
title: "Bottom spacing",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||||
|
const surface = await box(
|
||||||
|
page.getByTestId("message-input-root").filter({ visible: true }).first(),
|
||||||
|
);
|
||||||
|
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||||
|
expect(viewportHeight - surface.bottom).toBe(16);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the wide new workspace form keeps one content spacing above Import session", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: workspace.projectKey,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
|
||||||
|
const importButton = await box(page.getByTestId("new-workspace-import-session"));
|
||||||
|
expect(importButton.top - composer.bottom).toBe(16);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||||
|
import { fillComposerDraft } from "../support/helpers/composer";
|
||||||
import {
|
import {
|
||||||
|
expectComposerToolbarOnTextRails,
|
||||||
expectNoCollapsedComposerToolbarFrame,
|
expectNoCollapsedComposerToolbarFrame,
|
||||||
|
expectNoFrameTruncatesModelBesideThinking,
|
||||||
|
readSettledModelTriggerLabels,
|
||||||
recordComposerToolbarFrames,
|
recordComposerToolbarFrames,
|
||||||
|
recordModelTriggerFramesFromLoad,
|
||||||
|
sweepModelTriggerLabels,
|
||||||
} from "../support/helpers/composer-control-density";
|
} from "../support/helpers/composer-control-density";
|
||||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
const SETTLE_MS = 1_000;
|
const SETTLE_MS = 1_000;
|
||||||
@@ -92,3 +99,159 @@ test.describe("Composer control density across tab switches", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Composer controls folding on a touch device", () => {
|
||||||
|
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "composer-density-fold-",
|
||||||
|
title: "Folding chat",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
|
||||||
|
const thinkingMenu = page.getByTestId("combobox-desktop-container");
|
||||||
|
|
||||||
|
await thinking.click();
|
||||||
|
await expect(thinkingMenu).toBeVisible();
|
||||||
|
|
||||||
|
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
|
||||||
|
await page.setViewportSize({ width: 640, height: 834 });
|
||||||
|
await expect(thinking).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1194, height: 834 });
|
||||||
|
await expect(thinking).toBeVisible();
|
||||||
|
await expect(thinkingMenu).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Folded model control on a phone", () => {
|
||||||
|
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
|
||||||
|
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("hides the thinking label before the model label truncates", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "composer-density-labels-",
|
||||||
|
title: "Label priority chat",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await recordModelTriggerFramesFromLoad(page);
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
|
||||||
|
).toHaveText("Low");
|
||||||
|
await readSettledModelTriggerLabels(page);
|
||||||
|
// From the first frame on, the control never guesses: it paints both labels only once it
|
||||||
|
// has measured that they fit.
|
||||||
|
await expectNoFrameTruncatesModelBesideThinking(page);
|
||||||
|
|
||||||
|
const sweep = await sweepModelTriggerLabels(page, {
|
||||||
|
from: 480,
|
||||||
|
to: 280,
|
||||||
|
step: 4,
|
||||||
|
height: 844,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sweep.widest).toMatchObject({
|
||||||
|
modelText: "Ten second stream",
|
||||||
|
thinkingShown: true,
|
||||||
|
modelTruncated: false,
|
||||||
|
});
|
||||||
|
expect(sweep.thinkingHidden).toMatchObject({
|
||||||
|
modelText: "Ten second stream",
|
||||||
|
modelTruncated: false,
|
||||||
|
});
|
||||||
|
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
|
||||||
|
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
|
||||||
|
expect(sweep.modelTruncated).toMatchObject({
|
||||||
|
modelText: "Ten second…",
|
||||||
|
thinkingShown: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Composer toolbar rails on touch", () => {
|
||||||
|
test.use({ isMobile: true, hasTouch: true });
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ device: "phone", width: 390, height: 844 },
|
||||||
|
{ device: "tablet", width: 1024, height: 1366 },
|
||||||
|
]) {
|
||||||
|
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
const idle = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "composer-rails-idle-",
|
||||||
|
title: "Rails idle chat",
|
||||||
|
});
|
||||||
|
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, idle);
|
||||||
|
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
|
||||||
|
await expectComposerToolbarOnTextRails(page);
|
||||||
|
});
|
||||||
|
await test.step("draft: the send disc on the right", async () => {
|
||||||
|
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||||
|
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
|
||||||
|
await expectComposerToolbarOnTextRails(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
running = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "composer-rails-running-",
|
||||||
|
title: "Rails running chat",
|
||||||
|
model: "one-minute-stream",
|
||||||
|
initialPrompt: "stream for a while",
|
||||||
|
});
|
||||||
|
await openAgentRoute(page, running);
|
||||||
|
await test.step("running agent: the stop disc on the right", async () => {
|
||||||
|
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expectComposerToolbarOnTextRails(page);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await idle.cleanup();
|
||||||
|
await running?.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Composer trailing disc on a new chat", () => {
|
||||||
|
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||||
|
|
||||||
|
test("an empty draft holds a disabled send disc until there is something to send", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await clickNewChat(page);
|
||||||
|
const send = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByRole("button", { name: "Send message", exact: true });
|
||||||
|
await expect(send).toBeDisabled({ timeout: 30_000 });
|
||||||
|
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||||
|
await expect(send).toBeEnabled();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Locator } from "@playwright/test";
|
import type { CDPSession, Locator } from "@playwright/test";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { seedSubagent } from "../support/helpers/subagents";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +20,7 @@ function visibleMainPane(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function composerChangesPill(page: Page) {
|
function composerChangesPill(page: Page) {
|
||||||
return page.getByTestId("composer-diff-stat-pill");
|
return page.getByRole("button", { name: "Open Changes tab", exact: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revealComposerChangesInExplorer(page: Page) {
|
async function revealComposerChangesInExplorer(page: Page) {
|
||||||
@@ -78,7 +80,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
|
|||||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
// Keep both workspace rails visible alongside app navigation and Explorer.
|
||||||
|
await page.setViewportSize({ width: 1900, height: 900 });
|
||||||
await openAgentRoute(page, {
|
await openAgentRoute(page, {
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
agentId: workspace.agentId,
|
agentId: workspace.agentId,
|
||||||
@@ -498,6 +501,7 @@ test.describe("send review feedback", () => {
|
|||||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||||
const other = await seedChangedAgent("review-feedback-other-");
|
const other = await seedChangedAgent("review-feedback-other-");
|
||||||
try {
|
try {
|
||||||
@@ -858,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) {
|
|||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
|
||||||
|
const track = page.getByTestId("composer-track-scroll");
|
||||||
|
const box = await track.boundingBox();
|
||||||
|
if (!box) throw new Error("Composer tracks are not visible");
|
||||||
|
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
|
||||||
|
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
|
||||||
|
await swipeTouch(client, start, end, box.y + box.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
|
||||||
|
await client.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchStart",
|
||||||
|
touchPoints: [{ x: start, y }],
|
||||||
|
});
|
||||||
|
for (let step = 1; step <= 8; step++) {
|
||||||
|
await client.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchMove",
|
||||||
|
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackScrollOffset(track: Locator): Promise<number> {
|
||||||
|
return track.evaluate((node) => node.scrollLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("touch composer tracks", () => {
|
||||||
|
test.use({ hasTouch: true, isMobile: true });
|
||||||
|
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("composer-tracks-overflow-");
|
||||||
|
const touch = await page.context().newCDPSession(page);
|
||||||
|
try {
|
||||||
|
await seedSubagent(
|
||||||
|
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||||
|
{ parentId: workspace.agentId, title: "Track child" },
|
||||||
|
);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 844 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
await submitMessage(page, "emit 1 agent stream updates");
|
||||||
|
const tasks = page.getByTestId("agent-task-list-header");
|
||||||
|
const subagents = page.getByTestId("subagents-track-header");
|
||||||
|
const changes = page.getByTestId("composer-diff-stat-pill");
|
||||||
|
await expect(tasks).toBeVisible();
|
||||||
|
await expect(subagents).toBeVisible();
|
||||||
|
await expect(changes).toBeVisible();
|
||||||
|
const wideWidths = await Promise.all(
|
||||||
|
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||||
|
);
|
||||||
|
await page.setViewportSize({ width: 200, height: 844 });
|
||||||
|
const narrowWidths = await Promise.all(
|
||||||
|
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||||
|
);
|
||||||
|
for (const [index, width] of wideWidths.entries())
|
||||||
|
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||||
|
const track = page.getByTestId("composer-track-scroll");
|
||||||
|
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||||
|
await track.evaluate((node) => node.clientWidth),
|
||||||
|
);
|
||||||
|
await swipeComposerTracks(page, touch, "left");
|
||||||
|
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||||
|
await swipeComposerTracks(page, touch, "right");
|
||||||
|
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
|
||||||
|
await swipeComposerTracks(page, touch, "left");
|
||||||
|
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||||
|
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
|
||||||
|
await page.getByRole("button", { name: "Open menu" }).click();
|
||||||
|
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
|
||||||
|
await page.getByTestId("sidebar-close").click();
|
||||||
|
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||||
|
await expect(changes).toBeInViewport();
|
||||||
|
await changes.click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("changes-header").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await touch.detach();
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
expectMobileAgentSidebarVisible,
|
expectMobileAgentSidebarVisible,
|
||||||
expectMobileExplorerHidden,
|
expectMobileExplorerHidden,
|
||||||
expectMobileExplorerVisible,
|
expectMobileExplorerVisible,
|
||||||
swipeOpenMobileAgentSidebar,
|
openMobileAgentSidebarWithKeyboard,
|
||||||
swipeOpenMobileExplorer,
|
swipeOpenMobileExplorer,
|
||||||
} from "../support/helpers/sidebar";
|
} from "../support/helpers/sidebar";
|
||||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||||
@@ -249,7 +249,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
|||||||
const COMPACT_PANELS = [
|
const COMPACT_PANELS = [
|
||||||
{
|
{
|
||||||
name: "agent list",
|
name: "agent list",
|
||||||
open: swipeOpenMobileAgentSidebar,
|
open: openMobileAgentSidebarWithKeyboard,
|
||||||
expectOpen: expectMobileAgentSidebarVisible,
|
expectOpen: expectMobileAgentSidebarVisible,
|
||||||
close: closeMobileAgentSidebar,
|
close: closeMobileAgentSidebar,
|
||||||
expectClosed: expectMobileAgentSidebarHidden,
|
expectClosed: expectMobileAgentSidebarHidden,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
closeMobileAgentSidebar,
|
closeMobileAgentSidebar,
|
||||||
expectMobileAgentSidebarHidden,
|
expectMobileAgentSidebarHidden,
|
||||||
expectMobileAgentSidebarVisible,
|
expectMobileAgentSidebarVisible,
|
||||||
swipeOpenMobileAgentSidebar,
|
openMobileAgentSidebarWithKeyboard,
|
||||||
} from "../support/helpers/sidebar";
|
} from "../support/helpers/sidebar";
|
||||||
|
|
||||||
async function composerHeight(page: Page): Promise<number> {
|
async function composerHeight(page: Page): Promise<number> {
|
||||||
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
|
|||||||
await writeCompactDraft(page, draft);
|
await writeCompactDraft(page, draft);
|
||||||
await enterComposerFullscreen(page);
|
await enterComposerFullscreen(page);
|
||||||
await expectWorkspaceHeaderHidden(page);
|
await expectWorkspaceHeaderHidden(page);
|
||||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
|
||||||
await swipeOpenMobileAgentSidebar(page);
|
await openMobileAgentSidebarWithKeyboard(page);
|
||||||
await expectMobileAgentSidebarVisible(page);
|
await expectMobileAgentSidebarVisible(page);
|
||||||
// The list covers the pane, so the editor's exit action is not armed.
|
// The list covers the pane, so the editor's exit action is not armed.
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||||
import {
|
import {
|
||||||
openDaemonOverview,
|
openDaemonOverview,
|
||||||
@@ -13,6 +14,10 @@ import {
|
|||||||
readWorkerPid,
|
readWorkerPid,
|
||||||
openLongLivedTerminal,
|
openLongLivedTerminal,
|
||||||
listTerminalIds,
|
listTerminalIds,
|
||||||
|
suspendDaemonProcess,
|
||||||
|
resumeDaemonProcess,
|
||||||
|
daemonProcessCanWrite,
|
||||||
|
readTerminalProcessIds,
|
||||||
} from "../support/helpers/daemon-lifecycle";
|
} from "../support/helpers/daemon-lifecycle";
|
||||||
|
|
||||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||||
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
|
|||||||
);
|
);
|
||||||
return paseoHome;
|
return paseoHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
|
||||||
|
// A stopped worker deterministically outlives the old helper's five-second
|
||||||
|
// supervisor-only kill and logs into its deleted home when resumed.
|
||||||
|
cleanupTest(
|
||||||
|
"isolated cleanup prevents a stopped worker from recreating its deleted home",
|
||||||
|
async () => {
|
||||||
|
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||||
|
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||||
|
const workerPid = await readWorkerPid(daemon);
|
||||||
|
try {
|
||||||
|
suspendDaemonProcess(workerPid);
|
||||||
|
await daemon.close();
|
||||||
|
resumeDaemonProcess(workerPid);
|
||||||
|
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
|
||||||
|
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
resumeDaemonProcess(workerPid);
|
||||||
|
await daemon.close();
|
||||||
|
await rm(daemon.paseoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanupTest(
|
||||||
|
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
|
||||||
|
async () => {
|
||||||
|
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||||
|
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||||
|
const supervisorPid = daemon.getPid()!;
|
||||||
|
const workerPid = await readWorkerPid(daemon);
|
||||||
|
try {
|
||||||
|
await openLongLivedTerminal(daemon);
|
||||||
|
const terminalProcesses = await readTerminalProcessIds(daemon);
|
||||||
|
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
|
||||||
|
suspendDaemonProcess(supervisorPid);
|
||||||
|
await daemon.close();
|
||||||
|
const writerStates = await Promise.all(
|
||||||
|
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
|
||||||
|
);
|
||||||
|
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
|
||||||
|
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
resumeDaemonProcess(supervisorPid);
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, metroTest as test } from "../support/fixtures";
|
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
|
||||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
|
||||||
|
|
||||||
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
|
|
||||||
const PUBLISHED_DAEMON_VERSION = "0.8.0";
|
|
||||||
const COMPACT = { width: 390, height: 844 };
|
|
||||||
|
|
||||||
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
|
|
||||||
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
|
|
||||||
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
||||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
|
||||||
publishedVersion: PUBLISHED_DAEMON_VERSION,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const host = buildSeededHost({
|
|
||||||
serverId,
|
|
||||||
endpoint: `127.0.0.1:${daemon.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(
|
|
||||||
({ seededHost }) => {
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
|
||||||
},
|
|
||||||
{ seededHost: host },
|
|
||||||
);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await run();
|
|
||||||
} finally {
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectOutdatedNotice(page: Page): Promise<void> {
|
|
||||||
const callout = page.getByTestId("daemon-outdated-callout").first();
|
|
||||||
await expect(callout).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(callout).toContainText("Daemon out of date");
|
|
||||||
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
|
|
||||||
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
|
|
||||||
const update = page.getByTestId("daemon-outdated-callout-update").first();
|
|
||||||
await expect(update).toBeVisible();
|
|
||||||
await expect(update).toBeEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
await page.setViewportSize(COMPACT);
|
|
||||||
await withOutdatedDaemon(page, async () => {
|
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
|
||||||
await expectOutdatedNotice(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
|||||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
test.describe("Direct terminal shortcut pane placement", () => {
|
test.describe("Direct terminal shortcut pane placement", () => {
|
||||||
|
test.use({ viewport: { width: 1600, height: 900 } });
|
||||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
||||||
|
|
||||||
@@ -47,3 +50,88 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await clickNewChat(page);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
const draftPane = page
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ has: page.getByTestId("message-input-root") });
|
||||||
|
const emptyPane = page
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||||
|
const draftPaneId = await draftPane.getAttribute("data-testid");
|
||||||
|
const emptyPaneId = await emptyPane.getAttribute("data-testid");
|
||||||
|
expect(draftPaneId).not.toBeNull();
|
||||||
|
expect(emptyPaneId).not.toBeNull();
|
||||||
|
await page.setViewportSize({ width: 760, height: 900 });
|
||||||
|
return {
|
||||||
|
originalPane: page.getByTestId(draftPaneId!),
|
||||||
|
otherPane: page.getByTestId(emptyPaneId!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||||
|
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||||
|
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||||
|
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||||
|
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||||
|
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||||
|
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||||
|
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||||
|
try {
|
||||||
|
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||||
|
page,
|
||||||
|
workspace.workspaceId,
|
||||||
|
);
|
||||||
|
await launchTerminalFromCompactPane(page, originalPane);
|
||||||
|
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||||
|
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||||
|
await expect(otherPane).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
pane.evaluate((element) =>
|
||||||
|
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
|
||||||
|
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||||
|
try {
|
||||||
|
await installLocalDesktopHost(page, workspace.repoPath);
|
||||||
|
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||||
|
page,
|
||||||
|
workspace.workspaceId,
|
||||||
|
);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||||
|
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||||
|
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||||
|
await expectPaneHasWindowDragSurface(originalPane);
|
||||||
|
await expectPaneHasWindowDragSurface(otherPane);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user