mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Compare commits
401
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 | ||
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 | ||
|
|
e5fd61f131 | ||
|
|
487af3c822 | ||
|
|
a7439c8e56 | ||
|
|
930660074d | ||
|
|
fd030e8673 | ||
|
|
bb73147efd | ||
|
|
04b21f089c | ||
|
|
fb42c8eea2 | ||
|
|
971f74e2ab | ||
|
|
4318aa2bb1 | ||
|
|
a1761363fa | ||
|
|
096417d5ac | ||
|
|
35446e01a2 | ||
|
|
6c3559e90f | ||
|
|
409ab466ca | ||
|
|
efb8c8f229 | ||
|
|
b1d663557b | ||
|
|
a460c5e9b9 | ||
|
|
831e3289e7 | ||
|
|
094864d779 | ||
|
|
147482d6ed | ||
|
|
921ddf47a8 | ||
|
|
f287c7f972 | ||
|
|
9eb1f93296 | ||
|
|
95d56ddf15 | ||
|
|
62b1e94257 | ||
|
|
01bab92fed | ||
|
|
dae677edeb | ||
|
|
03a5323755 | ||
|
|
2e9f935dbc | ||
|
|
93845db70a | ||
|
|
d5085294df | ||
|
|
a3aed9b8b6 | ||
|
|
7ca428677c | ||
|
|
9ad3e7661b | ||
|
|
05a4e8f5a3 | ||
|
|
1b0fe4aa47 | ||
|
|
29876acae5 | ||
|
|
657b3e1ccd | ||
|
|
a2ab0af3f1 | ||
|
|
bb9e181c20 | ||
|
|
7bc1f04e27 | ||
|
|
394b9cac13 | ||
|
|
be41911083 | ||
|
|
8f11902cb7 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e | ||
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 | ||
|
|
f1cf4ebe60 | ||
|
|
0a452fad04 | ||
|
|
eb618638d2 | ||
|
|
7b8a2fbcef | ||
|
|
928169a4f2 | ||
|
|
0af666e3ac | ||
|
|
d41808efe1 | ||
|
|
aec28c8b67 | ||
|
|
3b5daa1451 | ||
|
|
f6579dbeb4 | ||
|
|
ab40786882 | ||
|
|
5f065c2685 | ||
|
|
6b50ace2bd | ||
|
|
c46c03158e | ||
|
|
7395fc6fd1 | ||
|
|
c35d649f6d | ||
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb | ||
|
|
ec8c51a014 | ||
|
|
84d3aa1962 | ||
|
|
0bc693f157 | ||
|
|
5ae7a0c9c2 | ||
|
|
e6bc752b68 | ||
|
|
78fc1fe862 | ||
|
|
84500cdcae | ||
|
|
480af26b57 | ||
|
|
2003f308f3 | ||
|
|
a042fbbe43 | ||
|
|
ee5577c2da | ||
|
|
24945a9498 | ||
|
|
f6f689d570 | ||
|
|
7703625e76 | ||
|
|
17b81fb132 | ||
|
|
111576e2ca | ||
|
|
862cc43db7 | ||
|
|
48924626df | ||
|
|
72ea9b7a72 | ||
|
|
e0f9b33d23 | ||
|
|
c7fe944e73 | ||
|
|
584f5ce05e | ||
|
|
614c085310 | ||
|
|
866aeb8ad9 | ||
|
|
23aaecd99a | ||
|
|
77fb74c188 | ||
|
|
70fb5f5bfd | ||
|
|
26c07b671f | ||
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [boudra]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
- "android-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -16,7 +17,7 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-android-apk:
|
publish-android-apk:
|
||||||
@@ -31,6 +32,18 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_tag="${SOURCE_TAG}"
|
||||||
|
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
|
release_tag="v${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
release_tag="$source_tag"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- 'app-v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,6 +26,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/app
|
run: npm run typecheck --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ on:
|
|||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'patches/**'
|
- 'patches/**'
|
||||||
- '.github/workflows/deploy-website.yml'
|
- '.github/workflows/deploy-website.yml'
|
||||||
|
release:
|
||||||
|
types: [published, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ name: Desktop Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- 'v*'
|
||||||
- "desktop-v*"
|
- 'desktop-v*'
|
||||||
- "desktop-macos-v*"
|
- 'desktop-macos-v*'
|
||||||
- "desktop-linux-v*"
|
- 'desktop-linux-v*'
|
||||||
- "desktop-windows-v*"
|
- 'desktop-windows-v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Existing tag to build (e.g. v0.1.0)"
|
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
platform:
|
platform:
|
||||||
description: "Optional desktop platform to build."
|
description: 'Optional desktop platform to build.'
|
||||||
required: false
|
required: false
|
||||||
default: "all"
|
default: 'all'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- all
|
- all
|
||||||
@@ -31,6 +31,8 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
||||||
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-macos:
|
publish-macos:
|
||||||
@@ -40,9 +42,9 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: macos-14
|
- runner: macos-14
|
||||||
rust_target: aarch64-apple-darwin
|
electron_arch: arm64
|
||||||
- runner: macos-15-intel
|
- runner: macos-15-intel
|
||||||
rust_target: x86_64-apple-darwin
|
electron_arch: x64
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -59,13 +61,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_tag="${SOURCE_TAG}"
|
source_tag="${SOURCE_TAG}"
|
||||||
release_tag="$source_tag"
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
else
|
||||||
release_tag="v${source_tag#${prefix}}"
|
release_tag="$source_tag"
|
||||||
break
|
fi
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
version="${release_tag#v}"
|
version="${release_tag#v}"
|
||||||
@@ -77,85 +77,39 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Import Apple code-signing certificate
|
|
||||||
uses: apple-actions/import-codesign-certs@v3
|
|
||||||
with:
|
|
||||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Sign bundled managed runtime
|
|
||||||
env:
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -164,76 +118,36 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build and publish macOS Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
id: tauri_build
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
|
||||||
releaseBody: See the assets to download and install this version.
|
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
|
||||||
prerelease: false
|
|
||||||
args: --target ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Notarize and re-upload DMG
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
EP_GH_IGNORE_TIME: true
|
||||||
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
publish_mode="never"
|
||||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
publish_args=()
|
||||||
if [ -z "$dmg_path" ]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
echo "::error::No DMG found in tauri build artifacts"
|
publish_mode="always"
|
||||||
exit 1
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
fi
|
fi
|
||||||
echo "DMG: $dmg_path"
|
|
||||||
|
|
||||||
echo "Signing DMG..."
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
|
||||||
|
|
||||||
echo "Submitting DMG for notarization..."
|
|
||||||
xcrun notarytool submit "$dmg_path" \
|
|
||||||
--apple-id "$APPLE_ID" \
|
|
||||||
--password "$APPLE_PASSWORD" \
|
|
||||||
--team-id "$APPLE_TEAM_ID" \
|
|
||||||
--wait
|
|
||||||
|
|
||||||
echo "Stapling notarization ticket..."
|
|
||||||
xcrun stapler staple "$dmg_path"
|
|
||||||
|
|
||||||
echo "Verifying..."
|
|
||||||
spctl --assess --type install --verbose "$dmg_path"
|
|
||||||
|
|
||||||
echo "Replacing release asset with notarized DMG..."
|
|
||||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
|
||||||
|
|
||||||
- name: Build macOS app (smoke only)
|
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||||
@@ -253,13 +167,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_tag="${SOURCE_TAG}"
|
source_tag="${SOURCE_TAG}"
|
||||||
release_tag="$source_tag"
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
else
|
||||||
release_tag="v${source_tag#${prefix}}"
|
release_tag="$source_tag"
|
||||||
break
|
fi
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
version="${release_tag#v}"
|
version="${release_tag#v}"
|
||||||
@@ -271,89 +183,38 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Install Linux packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-linux
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Strip CUDA dependencies from onnxruntime
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
node <<'NODE'
|
||||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
const fs = require('node:fs');
|
||||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
const path = require('node:path');
|
||||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
|
||||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
const version = process.env.DESKTOP_VERSION;
|
||||||
echo "Removing needed $lib from $f"
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
patchelf --remove-needed "$lib" "$f"
|
|
||||||
done
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
done
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
@@ -363,46 +224,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Linux Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
NO_STRIP: "true"
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
|
|
||||||
|
|
||||||
- name: Upload Linux release assets
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
EP_GH_IGNORE_TIME: true
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
publish_mode="never"
|
||||||
assets=(
|
publish_args=()
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
publish_mode="always"
|
||||||
)
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
|
||||||
echo "::error::No Linux AppImage assets were produced."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
|
|
||||||
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
|
|
||||||
|
|
||||||
- name: Build Linux app (smoke only)
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||||
@@ -422,13 +268,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_tag="${SOURCE_TAG}"
|
source_tag="${SOURCE_TAG}"
|
||||||
release_tag="$source_tag"
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
else
|
||||||
release_tag="v${source_tag#${prefix}}"
|
release_tag="$source_tag"
|
||||||
break
|
fi
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
version="${release_tag#v}"
|
version="${release_tag#v}"
|
||||||
@@ -440,71 +284,46 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-windows
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
- name: Build managed runtime
|
const version = process.env.DESKTOP_VERSION;
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build workspace dependencies
|
||||||
|
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||||
|
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||||
|
npx expo export --platform web
|
||||||
|
working-directory: packages/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
@@ -514,31 +333,28 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="draft"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build and publish Windows Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
shell: bash
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
EP_GH_IGNORE_TIME: true
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
run: |
|
||||||
with:
|
set -euo pipefail
|
||||||
projectPath: packages/desktop
|
publish_mode="never"
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
publish_args=()
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
releaseBody: See the assets to download and install this version.
|
publish_mode="always"
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
prerelease: false
|
fi
|
||||||
args: --bundles nsis
|
|
||||||
|
|
||||||
- name: Build Windows app (smoke only)
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Fix Nix hash
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fix-nix-hash:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Fix lockfile and update hash
|
||||||
|
run: ./scripts/update-nix.sh
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
run: |
|
||||||
|
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add package-lock.json nix/package.nix
|
||||||
|
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Nix Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Build Nix package
|
||||||
|
run: nix build .#default -o result
|
||||||
|
|
||||||
|
- name: Verify lockfile is complete
|
||||||
|
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||||
|
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||||
|
run: |
|
||||||
|
node scripts/fix-lockfile.mjs package-lock.json
|
||||||
|
git diff --exit-code package-lock.json || {
|
||||||
|
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||||
|
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||||
|
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check npmDepsHash is up to date
|
||||||
|
run: ./scripts/update-nix.sh --check
|
||||||
@@ -36,6 +36,9 @@ jobs:
|
|||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Build relay dependency
|
- name: Build relay dependency
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
@@ -44,3 +47,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm run test --workspace=@getpaseo/server
|
run: npm run test --workspace=@getpaseo/server
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ test-results/
|
|||||||
# Vercel
|
# Vercel
|
||||||
.vercel/
|
.vercel/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
.next
|
|
||||||
.expo
|
|
||||||
build
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Lock files
|
|
||||||
*.lock
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Generated
|
|
||||||
android
|
|
||||||
ios
|
|
||||||
.turbo
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
+159
-3
@@ -1,5 +1,161 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.38 - 2026-03-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
||||||
|
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
||||||
|
|
||||||
|
## 0.1.37 - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
|
||||||
|
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed broken PATH propagation and Claude binary resolution on Windows.
|
||||||
|
- Dictation errors now show a visible toast instead of failing silently.
|
||||||
|
|
||||||
|
## 0.1.36 - 2026-03-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Windows drive-letter path handling across the codebase.
|
||||||
|
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added metrics collection and terminal performance tests.
|
||||||
|
|
||||||
|
## 0.1.35 - 2026-03-26
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||||
|
- Codex file deletions now display correctly as removed lines in diffs.
|
||||||
|
- OpenCode questions are now surfaced in the permission UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed queued prompt dispatch after idle transition.
|
||||||
|
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added support for Nix and NixOS installation.
|
||||||
|
|
||||||
|
## 0.1.34 - 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||||
|
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||||
|
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||||
|
- Linked Android download surfaces to the Google Play Store.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||||
|
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||||
|
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||||
|
- Improved Codex activity logs with clearer tool-call summaries.
|
||||||
|
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||||
|
- Improved chat transcript readability.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed `paseo send --no-wait` not taking effect.
|
||||||
|
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||||
|
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||||
|
- Fixed nested Claude Code session detection and provider availability checks.
|
||||||
|
- Fixed agent input focus scoping across panels.
|
||||||
|
- Fixed terminal snapshot ordering when subscribing.
|
||||||
|
- Fixed `chat read --since` to accept message IDs.
|
||||||
|
- Fixed keyboard pane focus syncing with the active panel.
|
||||||
|
- Fixed assistant text selection on web.
|
||||||
|
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||||
|
- Fixed the attach-images button interaction in the message composer.
|
||||||
|
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||||
|
|
||||||
|
## 0.1.33 - 2026-03-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||||
|
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||||
|
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||||
|
- The notification test button now surfaces errors instead of failing silently.
|
||||||
|
|
||||||
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||||
|
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||||
|
- Added line numbers and word-wrap toggle to file previews.
|
||||||
|
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
|
||||||
|
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
|
||||||
|
- Expanded diff syntax highlighting to cover more languages.
|
||||||
|
- Added status bar tooltips for project and agent status.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||||
|
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||||
|
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||||
|
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
|
||||||
|
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
|
||||||
|
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
|
||||||
|
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
|
||||||
|
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
|
||||||
|
- Desktop key repeat now works correctly on macOS.
|
||||||
|
- Desktop notifications on macOS are more reliable.
|
||||||
|
- Daemon startup no longer blocks on model downloads.
|
||||||
|
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||||
|
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||||
|
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||||
|
- Fixed desktop window drag coordinates being applied when they shouldn't be.
|
||||||
|
|
||||||
|
## 0.1.30 - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||||
|
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||||
|
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||||
|
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||||
|
- Improved project picker path display and general workspace UI polish.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||||
|
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||||
|
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||||
|
|
||||||
|
## 0.1.28 - 2026-03-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added OpenCode build and plan modes.
|
||||||
|
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved the git action menu for more reliable repository actions.
|
||||||
|
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||||
|
- Updated the website hero copy and added a sponsor callout section.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||||
|
|
||||||
|
## 0.1.27 - 2026-03-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||||
|
- Added Grep tool support in Claude tool-call mapping.
|
||||||
|
- Added ability to open workspace files directly from agent chat messages.
|
||||||
|
- Added desktop notifications via a custom native bridge.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved image picker, markdown rendering, and UI interactions.
|
||||||
|
- Improved shell environment detection using shell-env.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed platform-specific markdown link rendering.
|
||||||
|
- Fixed Linux AppImage CLI resource paths.
|
||||||
|
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||||
|
|
||||||
## 0.1.26 - 2026-03-12
|
## 0.1.26 - 2026-03-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -118,7 +274,7 @@
|
|||||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||||
- Simplified website GitHub navigation and changelog headings.
|
- Simplified website GitHub navigation and changelog headings.
|
||||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||||
- Hid empty filter groups in the left sidebar.
|
- Hid empty filter groups in the left sidebar.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -140,7 +296,7 @@
|
|||||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||||
- Improved desktop settings and permissions UX in Tauri.
|
- Improved desktop settings and permissions UX on desktop.
|
||||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -179,7 +335,7 @@
|
|||||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||||
- Fixed Claude session identity preservation when reloading existing agents.
|
- Fixed Claude session identity preservation when reloading existing agents.
|
||||||
- Fixed combobox option behavior and related interactions.
|
- Fixed combobox option behavior and related interactions.
|
||||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||||
|
|
||||||
## 0.1.7 - 2026-02-16
|
## 0.1.7 - 2026-02-16
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
|
|||||||
- `packages/app` — Mobile + web client (Expo)
|
- `packages/app` — Mobile + web client (Expo)
|
||||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||||
- `packages/relay` — E2E encrypted relay for remote access
|
- `packages/relay` — E2E encrypted relay for remote access
|
||||||
- `packages/desktop` — Tauri desktop wrapper
|
- `packages/desktop` — Electron desktop wrapper
|
||||||
- `packages/website` — Marketing site (paseo.sh)
|
- `packages/website` — Marketing site (paseo.sh)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
npm run typecheck # Always run after changes
|
||||||
|
npm run db:query # Show DB table row counts
|
||||||
|
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||||
@@ -46,9 +48,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
|
|
||||||
## Orchestrator mode
|
## Debugging
|
||||||
|
|
||||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
|
||||||
- Launch agents in the most permissive mode
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
- Set cwd to the repository root
|
|
||||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
|
||||||
|
|||||||
@@ -4,39 +4,107 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> [!WARNING]
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
|
||||||
|
|
||||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||||
|
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||||
|
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||||
|
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||||
|
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
You need at least one agent CLI installed and configured with your credentials:
|
||||||
|
|
||||||
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
|
- [Codex](https://github.com/openai/codex)
|
||||||
|
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||||
|
|
||||||
|
### Desktop app (recommended)
|
||||||
|
|
||||||
|
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
||||||
|
|
||||||
|
To connect from your phone, scan the QR code shown in Settings.
|
||||||
|
|
||||||
|
### CLI / headless
|
||||||
|
|
||||||
|
Install the CLI and start Paseo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the app and connect to your daemon.
|
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Everything you can do in the app, you can do from the terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
|
paseo ls # list running agents
|
||||||
|
paseo attach abc123 # stream live output
|
||||||
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
|
# run on a remote daemon
|
||||||
|
paseo --host workstation.local:6767 run "run the full test suite"
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
|
|
||||||
|
## Orchestration skills (Unstable)
|
||||||
|
|
||||||
|
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add getpaseo/paseo
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use them in any agent conversation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||||
|
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||||
|
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||||
|
|
||||||
|
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||||
|
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||||
|
|
||||||
|
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||||
|
# Very opinionated and expects both Codex and Claude to work.
|
||||||
|
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Quick monorepo package map:
|
Quick monorepo package map:
|
||||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||||
- `packages/app`: Expo client (iOS, Android, web)
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
- `packages/desktop`: Tauri desktop app
|
- `packages/desktop`: Electron desktop app
|
||||||
- `packages/relay`: Relay package for remote connectivity
|
- `packages/relay`: Relay package for remote connectivity
|
||||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||||
|
|
||||||
@@ -49,12 +117,16 @@ npm run dev
|
|||||||
# run individual surfaces
|
# run individual surfaces
|
||||||
npm run dev:server
|
npm run dev:server
|
||||||
npm run dev:app
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
npm run dev:website
|
npm run dev:website
|
||||||
|
|
||||||
|
# build the daemon
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
# repo-wide checks
|
# repo-wide checks
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
AGPL-3.0
|
||||||
|
|||||||
+1
-1
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
|||||||
|
|
||||||
## Reporting vulnerabilities
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!*.lock"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"trailingCommas": "all",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": true,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -20,7 +20,7 @@ From repo root:
|
|||||||
```bash
|
```bash
|
||||||
npm run android:development # Debug build
|
npm run android:development # Debug build
|
||||||
npm run android:production # Release build
|
npm run android:production # Release build
|
||||||
npm run android:clean # Clean native project
|
npm run android:clear # Remove generated Android project
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from `packages/app`:
|
Or from `packages/app`:
|
||||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
|||||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||||
APP_VARIANT=production npx expo run:android --variant=release
|
APP_VARIANT=production npx expo run:android --variant=release
|
||||||
|
|
||||||
# Clean
|
# Clear generated Android project
|
||||||
npx expo prebuild --platform android --clean --non-interactive
|
rm -rf android
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|||||||
+12
-4
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
|
|||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ WebSocket │ WebSocket │ Managed subprocess
|
│ WebSocket │ WebSocket │ Managed subprocess
|
||||||
@@ -31,6 +31,14 @@ Your code never leaves your machine. Paseo is local-first.
|
|||||||
└───────────┘ └────────┘ └──────────┘
|
└───────────┘ └────────┘ └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Components at a glance
|
||||||
|
|
||||||
|
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
|
||||||
|
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
|
||||||
|
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
|
||||||
|
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
|
||||||
|
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### `packages/server` — The daemon
|
### `packages/server` — The daemon
|
||||||
@@ -90,9 +98,9 @@ Enables remote access when the daemon is behind a firewall.
|
|||||||
|
|
||||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
### `packages/desktop` — Desktop app (Tauri)
|
### `packages/desktop` — Desktop app (Electron)
|
||||||
|
|
||||||
Tauri wrapper for macOS, Linux, and Windows.
|
Electron wrapper for macOS, Linux, and Windows.
|
||||||
|
|
||||||
- Can spawn the daemon as a managed subprocess
|
- Can spawn the daemon as a managed subprocess
|
||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
@@ -180,5 +188,5 @@ $PASEO_HOME/
|
|||||||
## 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`
|
||||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
|||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||||
|
|
||||||
|
### Database queries
|
||||||
|
|
||||||
|
Run arbitrary SQL against the SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show table row counts
|
||||||
|
npm run db:query
|
||||||
|
|
||||||
|
# Run any SQL
|
||||||
|
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||||
|
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||||
|
|
||||||
|
# Point at a specific DB directory
|
||||||
|
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||||
|
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||||
|
|
||||||
## Build sync gotchas
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### Relay → Daemon
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# File Icons
|
||||||
|
|
||||||
|
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||||
|
|
||||||
|
Icons are inlined as SVG strings in:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/app/src/components/material-file-icons.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is auto-generated. Do not edit it by hand.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||||
|
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||||
|
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||||
|
|
||||||
|
## Adding a new icon
|
||||||
|
|
||||||
|
1. Find the icon name in the material-icon-theme manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "
|
||||||
|
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
|
||||||
|
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
|
||||||
|
console.log('languageIds:', m.languageIds['YOUR_LANG']);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify the SVG exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add two things to `material-file-icons.ts`:
|
||||||
|
|
||||||
|
- The SVG string in `SVG_ICONS`:
|
||||||
|
```ts
|
||||||
|
"icon_name": `<svg ...>...</svg>`,
|
||||||
|
```
|
||||||
|
|
||||||
|
- The extension mapping in `EXTENSION_TO_ICON`:
|
||||||
|
```ts
|
||||||
|
"ext": "icon_name",
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run `npm run typecheck` to verify.
|
||||||
|
|
||||||
|
## Currently included icons
|
||||||
|
|
||||||
|
53 unique icons covering these extensions:
|
||||||
|
|
||||||
|
| Extension(s) | Icon |
|
||||||
|
|---|---|
|
||||||
|
| `ts` | typescript |
|
||||||
|
| `tsx` | react_ts |
|
||||||
|
| `js` | javascript |
|
||||||
|
| `jsx` | react |
|
||||||
|
| `py` | python |
|
||||||
|
| `go` | go |
|
||||||
|
| `rs` | rust |
|
||||||
|
| `rb` | ruby |
|
||||||
|
| `java` | java |
|
||||||
|
| `kt` | kotlin |
|
||||||
|
| `c` | c |
|
||||||
|
| `cpp` | cpp |
|
||||||
|
| `h` | h |
|
||||||
|
| `hpp` | hpp |
|
||||||
|
| `cs` | csharp |
|
||||||
|
| `swift` | swift |
|
||||||
|
| `dart` | dart |
|
||||||
|
| `ex`, `exs` | elixir |
|
||||||
|
| `erl` | erlang |
|
||||||
|
| `hs` | haskell |
|
||||||
|
| `clj` | clojure |
|
||||||
|
| `scala` | scala |
|
||||||
|
| `ml` | ocaml |
|
||||||
|
| `r` | r |
|
||||||
|
| `lua` | lua |
|
||||||
|
| `zig` | zig |
|
||||||
|
| `nix` | nix |
|
||||||
|
| `php` | php |
|
||||||
|
| `html` | html |
|
||||||
|
| `css` | css |
|
||||||
|
| `scss` | sass |
|
||||||
|
| `less` | less |
|
||||||
|
| `json` | json |
|
||||||
|
| `yml`, `yaml` | yaml |
|
||||||
|
| `xml` | xml |
|
||||||
|
| `toml` | toml |
|
||||||
|
| `md`, `markdown` | markdown |
|
||||||
|
| `sql` | database |
|
||||||
|
| `graphql`, `gql` | graphql |
|
||||||
|
| `sh`, `bash` | console |
|
||||||
|
| `tf` | terraform |
|
||||||
|
| `hcl` | hcl |
|
||||||
|
| `vue` | vue |
|
||||||
|
| `svelte` | svelte |
|
||||||
|
| `astro` | astro |
|
||||||
|
| `wasm` | webassembly |
|
||||||
|
| `svg` | svg |
|
||||||
|
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||||
|
| `txt` | document |
|
||||||
|
| `conf`, `cfg`, `ini` | settings |
|
||||||
|
| `lock` | lock |
|
||||||
|
| `groovy` | groovy |
|
||||||
|
| `gradle` | gradle |
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
What Paseo is, who it's for, and where it's going.
|
||||||
|
|
||||||
|
## What is Paseo
|
||||||
|
|
||||||
|
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||||
|
|
||||||
|
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||||
|
|
||||||
|
## Core philosophy
|
||||||
|
|
||||||
|
Freedom and flexibility. Every design decision follows from this:
|
||||||
|
|
||||||
|
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||||
|
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||||
|
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||||
|
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||||
|
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||||
|
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Projects and workspaces
|
||||||
|
|
||||||
|
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||||
|
|
||||||
|
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||||
|
|
||||||
|
### Inside a workspace
|
||||||
|
|
||||||
|
A workspace is a flexible canvas:
|
||||||
|
- Launch multiple agents side by side in split panes
|
||||||
|
- Open terminals alongside agents
|
||||||
|
- Mix and match providers within the same workspace
|
||||||
|
|
||||||
|
### The daemon
|
||||||
|
|
||||||
|
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||||
|
|
||||||
|
This architecture means:
|
||||||
|
- The daemon can run on any machine: laptop, VM, remote server
|
||||||
|
- Multiple clients can connect simultaneously
|
||||||
|
- Agents keep running when you close the app
|
||||||
|
|
||||||
|
## Target user
|
||||||
|
|
||||||
|
Anyone who builds software:
|
||||||
|
|
||||||
|
- Care about owning their tools and their data
|
||||||
|
- Use multiple AI providers and want to switch freely
|
||||||
|
- Run agents on real tasks across real projects
|
||||||
|
- Want to work from multiple devices
|
||||||
|
|
||||||
|
## What compounds over time
|
||||||
|
|
||||||
|
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||||
|
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||||
|
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||||
|
|
||||||
|
## Strategic bets
|
||||||
|
|
||||||
|
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||||
|
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||||
|
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||||
|
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||||
|
|
||||||
|
## Current state (March 2026)
|
||||||
|
|
||||||
|
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||||
|
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||||
|
- Daily releases
|
||||||
|
- Community contributions starting (packaging, bug fixes)
|
||||||
|
- Key UX: split panes, keybinding customization, workspace model
|
||||||
+41
-3
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
|
|||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:check # Validate release
|
|
||||||
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)
|
||||||
```
|
```
|
||||||
@@ -25,24 +25,62 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||||
|
# ... test builds from the draft release assets ...
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
npm run release:finalize # Publish npm, promote draft to published
|
||||||
```
|
```
|
||||||
|
|
||||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- `release:finalize` publishes npm and promotes the same draft release
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- Use the same semver tag for both; don't cut a second tag
|
||||||
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||||
|
|
||||||
|
## Fixing a failed release build
|
||||||
|
|
||||||
|
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||||
|
|
||||||
|
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||||
|
|
||||||
|
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desktop (all platforms)
|
||||||
|
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
|
||||||
|
|
||||||
|
# Desktop (single platform)
|
||||||
|
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
|
||||||
|
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
|
||||||
|
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
|
||||||
|
|
||||||
|
# Android APK
|
||||||
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||||
|
|
||||||
|
## Changelog format
|
||||||
|
|
||||||
|
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||||
|
|
||||||
|
```
|
||||||
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] `npm run release:patch` completes successfully
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
|
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Storage Revamp Plan
|
||||||
|
|
||||||
|
Status: active rollout, phases 1 and 2 complete
|
||||||
|
|
||||||
|
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
|
||||||
|
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
|
||||||
|
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- make structured records durable in Drizzle + SQLite
|
||||||
|
- make projects and workspaces explicit first-class records
|
||||||
|
- stop deriving project/workspace identity from agent `cwd`
|
||||||
|
- keep agent snapshot persistence behind clear ownership
|
||||||
|
- move committed timeline history to storage-owned rows
|
||||||
|
- remove legacy JSON and in-memory authority once the DB path is proven
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- moving config, keypairs, push tokens, or server identity into the DB
|
||||||
|
- persisting raw provider deltas or transport-only chunk streams
|
||||||
|
- designing a hosted/remote database story beyond keeping the schema portable
|
||||||
|
- durable reasoning history unless product explicitly asks for it later
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
The storage revamp is no longer hypothetical.
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
|
||||||
|
- Drizzle + SQLite database bootstrap is in place
|
||||||
|
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
|
||||||
|
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
|
||||||
|
- `agent_snapshots.workspace_id` is `NOT NULL`
|
||||||
|
- legacy JSON import feeds the DB-backed structured records
|
||||||
|
- project/workspace records use explicit `directory` fields instead of path-as-identity
|
||||||
|
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
|
||||||
|
- `workspace-reconciliation-service.ts` is deleted
|
||||||
|
- `workspace-registry-bootstrap.ts` is deleted
|
||||||
|
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
|
||||||
|
|
||||||
|
Still pending:
|
||||||
|
|
||||||
|
- explicit `create_project` / `create_workspace` API cleanup
|
||||||
|
- final archive cascade behavior for descendants and live agents
|
||||||
|
- committed timeline storage cutover
|
||||||
|
- removal of remaining legacy JSON and in-memory committed-history authority
|
||||||
|
|
||||||
|
## Converged decisions
|
||||||
|
|
||||||
|
### Structured record authority
|
||||||
|
|
||||||
|
Projects, workspaces, and agent snapshots are DB-backed structured records.
|
||||||
|
The server should not recreate project/workspace identity from:
|
||||||
|
|
||||||
|
- git remotes
|
||||||
|
- worktree main-repo roots
|
||||||
|
- normalized cwd strings
|
||||||
|
|
||||||
|
Temporary exception:
|
||||||
|
|
||||||
|
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
|
||||||
|
`workspaceId` explicitly
|
||||||
|
|
||||||
|
That fallback is transitional and should be deleted once the client always sends the workspace id.
|
||||||
|
|
||||||
|
### Storage seams
|
||||||
|
|
||||||
|
The useful seams remain concrete and domain-shaped:
|
||||||
|
|
||||||
|
- `ProjectRegistry`
|
||||||
|
- `WorkspaceRegistry`
|
||||||
|
- `AgentSnapshotStore`
|
||||||
|
- `AgentTimelineStore`
|
||||||
|
|
||||||
|
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
|
||||||
|
|
||||||
|
### Timeline contract
|
||||||
|
|
||||||
|
The long-term timeline contract remains:
|
||||||
|
|
||||||
|
- committed rows are durable, canonical history
|
||||||
|
- provisional live updates are transient subscription state
|
||||||
|
- committed history is fetched by seq
|
||||||
|
- provider history replay is not the durability mechanism
|
||||||
|
|
||||||
|
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
|
||||||
|
stable DB-backed agent and workspace identity.
|
||||||
|
|
||||||
|
## Remaining phases
|
||||||
|
|
||||||
|
### Phase 3: Explicit creation and archive cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove the last transitional write paths that still infer state from directories.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- add explicit `create_project` handling
|
||||||
|
- add explicit `create_workspace` handling
|
||||||
|
- make agent creation require `workspaceId` once the UI is ready
|
||||||
|
- finish archive semantics for workspaces/projects and any descendant agent state
|
||||||
|
- remove the temporary find-or-create-by-directory fallback from agent creation
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- project/workspace creation is explicit end to end
|
||||||
|
- no normal creation path infers identity from cwd or git metadata
|
||||||
|
- archive flows behave consistently for structured records and live runtime state
|
||||||
|
|
||||||
|
### Phase 4: Timeline storage cutover
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Make committed history durable and storage-owned.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- make `AgentTimelineStore` authoritative for committed history
|
||||||
|
- write one committed row per finalized logical item
|
||||||
|
- support tail, before-seq, and after-seq queries from storage
|
||||||
|
- stop treating provider history hydration as the normal refresh/load path
|
||||||
|
- keep provisional live updates in memory only
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- committed history survives daemon restart
|
||||||
|
- reconnect uses committed catch-up plus future live events without gaps or duplicates
|
||||||
|
- unloaded agents can serve committed history from storage alone
|
||||||
|
|
||||||
|
### Phase 5: Legacy cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove compatibility paths after the DB-backed model is fully authoritative.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- remove legacy JSON authority for structured records
|
||||||
|
- remove in-memory committed-history ownership
|
||||||
|
- remove provider-history rehydrate compatibility paths
|
||||||
|
- trim dead protocol and reducer logic from the pre-storage model
|
||||||
|
- update architecture docs to match the final model
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- there is one durable storage path for structured records
|
||||||
|
- there is one durable storage path for committed timeline history
|
||||||
|
- the runtime no longer depends on the removed JSON/in-memory model
|
||||||
|
|
||||||
|
## Data model summary
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `git | directory`
|
||||||
|
- optional `git_remote`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Workspaces
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- belongs to a project by `project_id`
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `checkout | worktree`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Agent snapshots
|
||||||
|
|
||||||
|
- `agent_id` remains the primary key
|
||||||
|
- belongs to a workspace by integer `workspace_id`
|
||||||
|
- `workspace_id` is required
|
||||||
|
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
|
||||||
|
|
||||||
|
### Timeline rows
|
||||||
|
|
||||||
|
Target shape once Phase 4 lands:
|
||||||
|
|
||||||
|
- `agent_id`
|
||||||
|
- committed `seq`
|
||||||
|
- committed timestamp
|
||||||
|
- canonical finalized item payload
|
||||||
|
|
||||||
|
Not part of durable history:
|
||||||
|
|
||||||
|
- raw streaming chunks
|
||||||
|
- provisional assistant text
|
||||||
|
- provisional reasoning text
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
Every remaining phase should keep the same bar:
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- targeted tests for the touched storage/session/runtime paths
|
||||||
|
- migration/import coverage when storage authority changes
|
||||||
|
- reconnect and catch-up scenario coverage when timeline behavior changes
|
||||||
|
|
||||||
|
At minimum, timeline cutover must explicitly prove:
|
||||||
|
|
||||||
|
- `fetch-after-seq`
|
||||||
|
- `fetch-before-seq`
|
||||||
|
- restart durability
|
||||||
|
- no-gap/no-duplicate reconnect behavior
|
||||||
|
|
||||||
|
## Main risks
|
||||||
|
|
||||||
|
- timeline work reintroduces provider-history replay as hidden authority
|
||||||
|
- archive behavior diverges between stored records and live in-memory agents
|
||||||
|
- explicit creation work leaves the transitional cwd fallback in place too long
|
||||||
|
- cleanup stalls after compatibility paths stop being exercised
|
||||||
|
|
||||||
|
## Rule of thumb
|
||||||
|
|
||||||
|
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
|
||||||
|
it is probably moving in the wrong direction.
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Terminal Mode — Implementation Plan
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI.
|
||||||
|
|
||||||
|
**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### Phase 1: Server — Data Model & Provider Interface
|
||||||
|
|
||||||
|
#### 1.1 Add `terminal` flag to `ManagedAgentBase`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ManagedAgentBase = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal: boolean; // NEW — if true, this agent renders as a terminal TUI
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This flag is set at creation time and never changes. A terminal agent is always a terminal agent.
|
||||||
|
|
||||||
|
#### 1.2 Add `terminal` to `AgentSessionConfig`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentSessionConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal?: boolean; // NEW — create as terminal agent
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 Add `terminal` to the Zod schema
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
Add to `AgentSessionConfigSchema`:
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `AgentStateSchema` (the wire format sent to clients):
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 Add terminal command builders to `AgentClient`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TerminalCommand = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentClient {
|
||||||
|
// ...existing methods...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to launch this agent's TUI for a new session.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to resume an existing session in the agent's TUI.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5 Add `supportsTerminalMode` capability
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentCapabilityFlags = {
|
||||||
|
// ...existing flags...
|
||||||
|
supportsTerminalMode: boolean; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add to the Zod schema in `messages.ts`:
|
||||||
|
```typescript
|
||||||
|
supportsTerminalMode: z.boolean(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.6 Implement terminal command builders in providers
|
||||||
|
|
||||||
|
**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.modeId === "bypassPermissions") {
|
||||||
|
args.push("--dangerously-skip-permissions");
|
||||||
|
}
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
// mode mapping: default → nothing, plan → --plan, etc.
|
||||||
|
return { command: "claude", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "claude",
|
||||||
|
args: ["--resume", handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
if (config.modeId) args.push("--approval-mode", config.modeId);
|
||||||
|
return { command: "codex", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "codex",
|
||||||
|
args: ["--resume", handle.nativeHandle ?? handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
return { command: "opencode", args: [], env: {} };
|
||||||
|
}
|
||||||
|
// No resume support for OpenCode initially
|
||||||
|
```
|
||||||
|
|
||||||
|
Capabilities for each provider:
|
||||||
|
- Claude: `supportsTerminalMode: true`
|
||||||
|
- Codex: `supportsTerminalMode: true`
|
||||||
|
- OpenCode: `supportsTerminalMode: true`
|
||||||
|
|
||||||
|
#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
When `config.terminal === true`:
|
||||||
|
1. Do NOT call `client.createSession()` — there is no managed session
|
||||||
|
2. Call `client.buildTerminalCreateCommand(config)` to get the command
|
||||||
|
3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command
|
||||||
|
4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null`
|
||||||
|
5. Store the terminal ID in the agent's metadata or a new field
|
||||||
|
6. The agent's persistence handle can be populated later (the CLI will create its own session file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record<string, string> }): Promise<ManagedAgent> {
|
||||||
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
|
||||||
|
if (normalizedConfig.terminal) {
|
||||||
|
// Terminal mode — no managed session, just build the command
|
||||||
|
const buildCmd = client.buildTerminalCreateCommand;
|
||||||
|
if (!buildCmd) {
|
||||||
|
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||||
|
}
|
||||||
|
const cmd = buildCmd.call(client, normalizedConfig);
|
||||||
|
return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, {
|
||||||
|
labels: options?.labels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...existing managed agent flow...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New method `registerTerminalAgent()`:
|
||||||
|
- Creates a ManagedAgent with `terminal: true`
|
||||||
|
- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect)
|
||||||
|
- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state)
|
||||||
|
- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents)
|
||||||
|
- Broadcasts `agent_state` event so clients know about it
|
||||||
|
|
||||||
|
#### 1.8 New message: create terminal for agent
|
||||||
|
|
||||||
|
The client needs a way to request a terminal for a terminal agent. Options:
|
||||||
|
|
||||||
|
**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command.
|
||||||
|
|
||||||
|
**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step.
|
||||||
|
|
||||||
|
**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided:
|
||||||
|
- Look up the agent (must be a terminal agent)
|
||||||
|
- Use the agent's stored command to create the terminal
|
||||||
|
- Associate the terminal with the agent
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CreateTerminalRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("create_terminal_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.9 Terminal → Agent lifecycle binding
|
||||||
|
|
||||||
|
When a terminal associated with a terminal agent exits:
|
||||||
|
- Set agent lifecycle to `"closed"`
|
||||||
|
- Attempt to detect the agent's session file for persistence handle
|
||||||
|
- Broadcast state update
|
||||||
|
|
||||||
|
When a terminal agent is opened from the sessions page:
|
||||||
|
- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists
|
||||||
|
- Otherwise calls `buildTerminalCreateCommand(config)`
|
||||||
|
- Creates a new terminal with that command
|
||||||
|
|
||||||
|
#### 1.10 Extend `createTerminal()` to support command + args
|
||||||
|
|
||||||
|
**File:** `packages/server/src/terminal/terminal.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CreateTerminalOptions {
|
||||||
|
cwd: string;
|
||||||
|
shell?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
name?: string;
|
||||||
|
command?: string; // NEW — if provided, run this instead of shell
|
||||||
|
args?: string[]; // NEW — arguments for command
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `createTerminal()`:
|
||||||
|
```typescript
|
||||||
|
const spawnCommand = options.command ?? shell;
|
||||||
|
const spawnArgs = options.command ? (options.args ?? []) : [];
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cols, rows, cwd,
|
||||||
|
env: { ...process.env, ...env, TERM: "xterm-256color" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: App — Draft UI & Terminal Toggle
|
||||||
|
|
||||||
|
#### 2.1 Add terminal toggle to draft tab
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx`
|
||||||
|
|
||||||
|
Add a toggle switch in the draft UI: **"Chat" / "Terminal"**
|
||||||
|
|
||||||
|
State:
|
||||||
|
```typescript
|
||||||
|
const [isTerminalMode, setIsTerminalMode] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
The toggle should be persistent per draft (stored in the draft store or as a preference).
|
||||||
|
|
||||||
|
When terminal mode is selected:
|
||||||
|
- The provider/model pickers still work (same UI)
|
||||||
|
- The mode picker still works
|
||||||
|
- The "send" button label changes to "Launch" or "Start"
|
||||||
|
- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI)
|
||||||
|
|
||||||
|
#### 2.2 Modify agent creation to pass `terminal: true`
|
||||||
|
|
||||||
|
When the user submits a draft in terminal mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config: AgentSessionConfig = {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
model: selectedModel,
|
||||||
|
modeId: selectedMode,
|
||||||
|
terminal: true, // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed.
|
||||||
|
|
||||||
|
#### 2.3 Terminal mode in `AgentStatusBar`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/components/agent-status-bar.tsx`
|
||||||
|
|
||||||
|
When rendering a draft's status bar, filter the capability:
|
||||||
|
- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected
|
||||||
|
- The terminal toggle can live next to the provider selector or as a segmented control above the input area
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: App — Agent Panel Rendering
|
||||||
|
|
||||||
|
#### 3.1 Branch rendering in `AgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function AgentPanelContent({ agentId, ... }) {
|
||||||
|
const agent = useAgentState(agentId);
|
||||||
|
|
||||||
|
if (agent?.terminal) {
|
||||||
|
return <TerminalAgentPanel agentId={agentId} agent={agent} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AgentPanelBody agent={agent} ... />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 New component: `TerminalAgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file)
|
||||||
|
|
||||||
|
This component:
|
||||||
|
1. Gets the terminal ID associated with the agent (from agent metadata or a new field)
|
||||||
|
2. Renders a `TerminalPane` connected to that terminal session
|
||||||
|
3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })`
|
||||||
|
4. Handles terminal exit → agent close lifecycle
|
||||||
|
|
||||||
|
Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal.
|
||||||
|
|
||||||
|
#### 3.3 Tab descriptor for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor`
|
||||||
|
|
||||||
|
The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge.
|
||||||
|
|
||||||
|
Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Sessions Page
|
||||||
|
|
||||||
|
#### 4.1 Terminal agents appear in sessions list
|
||||||
|
|
||||||
|
No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`.
|
||||||
|
|
||||||
|
#### 4.2 Opening a terminal agent from sessions
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/sessions/` (sessions screen)
|
||||||
|
|
||||||
|
When the user clicks a closed terminal agent:
|
||||||
|
1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists
|
||||||
|
2. Creates a new terminal with that command
|
||||||
|
3. Opens agent tab in workspace
|
||||||
|
|
||||||
|
If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: CLI Gating
|
||||||
|
|
||||||
|
#### 5.1 `paseo send` — error for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/cli/src/commands/send.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (agent.terminal) {
|
||||||
|
throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 `paseo run` — could support `--terminal` flag (future)
|
||||||
|
|
||||||
|
Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only.
|
||||||
|
|
||||||
|
#### 5.3 `paseo ls` — show terminal flag
|
||||||
|
|
||||||
|
Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wire Format Changes Summary
|
||||||
|
|
||||||
|
### AgentSessionConfig (create request)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentState (server → client)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
lifecycle: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentCapabilityFlags
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
supportsStreaming: boolean;
|
||||||
|
supportsSessionPersistence: boolean;
|
||||||
|
+ supportsTerminalMode: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CreateTerminalRequest
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
type: "create_terminal_request";
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
+ agentId?: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TerminalCommand (new type)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases & Agent Assignments
|
||||||
|
|
||||||
|
### Phase 1: Server data model (1 agent)
|
||||||
|
- Add `terminal` to types, schemas, and agent manager
|
||||||
|
- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient`
|
||||||
|
- Add `supportsTerminalMode` capability flag
|
||||||
|
- Extend `createTerminal()` to support command+args
|
||||||
|
- Implement terminal agent creation flow in `AgentManager`
|
||||||
|
- Wire terminal exit → agent close lifecycle
|
||||||
|
- Implement command builders in Claude, Codex, OpenCode providers
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 2: App draft UI + terminal toggle (1 agent)
|
||||||
|
- Add terminal mode toggle to `workspace-draft-agent-tab.tsx`
|
||||||
|
- Pass `terminal: true` in config when toggle is on
|
||||||
|
- Filter toggle based on `supportsTerminalMode` capability
|
||||||
|
- Persist toggle preference
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 3: App panel rendering (1 agent)
|
||||||
|
- Branch `AgentPanelContent` on `agent.terminal`
|
||||||
|
- Create `TerminalAgentPanel` component
|
||||||
|
- Handle terminal creation for agent on open
|
||||||
|
- Handle terminal exit lifecycle
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 4: Sessions page + CLI gating (1 agent)
|
||||||
|
- Terminal agents show in sessions with badge
|
||||||
|
- Opening from sessions resumes or creates terminal
|
||||||
|
- `paseo send` errors for terminal agents
|
||||||
|
- `paseo ls` shows terminal badge
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Interaction Guards
|
||||||
|
|
||||||
|
Terminal agents are explicitly excluded from automated dispatch paths:
|
||||||
|
|
||||||
|
- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false`
|
||||||
|
- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false`
|
||||||
|
- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send
|
||||||
|
- **CLI `paseo send`**: Returns error for terminal agents
|
||||||
|
- **MCP agent creation**: Programmatic paths don't pass `terminal: true`
|
||||||
|
|
||||||
|
All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents.
|
||||||
|
|
||||||
|
## What This Does NOT Change
|
||||||
|
|
||||||
|
- The existing managed agent flow is untouched
|
||||||
|
- Terminal sessions (non-agent) still work as before
|
||||||
|
- The `AgentSession` interface is unchanged
|
||||||
|
- Mobile experience is unchanged (terminal mode is web/desktop only for now)
|
||||||
|
- No new providers are added (existing providers gain terminal command builders)
|
||||||
|
- No hooks, no env injection, no process tree detection (v1 keeps it simple)
|
||||||
|
|
||||||
|
## Future Work (Not In This Plan)
|
||||||
|
|
||||||
|
- Auto-detect agent type from PTY process tree (for standalone terminals)
|
||||||
|
- "Convert to chat" / "Convert to terminal" actions
|
||||||
|
- Terminal title/icon from OSC sequences
|
||||||
|
- `paseo run --terminal` CLI support
|
||||||
|
- Mobile terminal mode (if xterm.js works well enough on mobile web)
|
||||||
|
- Gemini / Aider / Goose provider definitions (terminal-only providers)
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772963539,
|
||||||
|
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = paseo;
|
||||||
|
paseo = paseo;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = self.nixosModules.paseo;
|
||||||
|
nixosModules.paseo =
|
||||||
|
{ pkgs, lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./nix/module.nix ];
|
||||||
|
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
pkgs.python3
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.paseo;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.paseo = {
|
||||||
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "paseo" { };
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "User account under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "Group under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default =
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/${cfg.user}/.paseo";
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/''${cfg.user}/.paseo"
|
||||||
|
'';
|
||||||
|
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 6767;
|
||||||
|
description = "Port for the Paseo daemon to listen on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
listenAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address for the Paseo daemon to bind to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedHosts = lib.mkOption {
|
||||||
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
|
default = [ ];
|
||||||
|
example = [ ".example.com" "myhost.local" ];
|
||||||
|
description = ''
|
||||||
|
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||||
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
|
Use a leading dot to match a domain and all its subdomains
|
||||||
|
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||||
|
|
||||||
|
Set to `true` to allow any host (not recommended).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
relay = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
inheritUserEnvironment = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.user != "paseo";
|
||||||
|
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||||
|
description = ''
|
||||||
|
Whether to include the user's profile PATH in the service environment.
|
||||||
|
|
||||||
|
When Paseo runs as a real user (not the default system user), AI agents
|
||||||
|
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||||
|
NixOS profile and system paths so agents can use them without manually
|
||||||
|
setting PATH.
|
||||||
|
|
||||||
|
Enabled by default when `user` is set to a non-default value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = "Extra environment variables for the Paseo daemon.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.paseo = {
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PASEO_HOME = cfg.dataDir;
|
||||||
|
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||||
|
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||||
|
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||||
|
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||||
|
# includes /run/current-system/sw/bin which is a superset of those.
|
||||||
|
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||||
|
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||||
|
"/run/current-system/sw/bin"
|
||||||
|
"/run/wrappers/bin"
|
||||||
|
"/nix/var/nix/profiles/default/bin"
|
||||||
|
]);
|
||||||
|
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||||
|
PASEO_ALLOWED_HOSTS = "true";
|
||||||
|
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||||
|
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
|
||||||
|
ExecStart =
|
||||||
|
"${cfg.package}/bin/paseo-server"
|
||||||
|
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
TimeoutStopSec = 15;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenv,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs_22,
|
||||||
|
python3,
|
||||||
|
makeWrapper,
|
||||||
|
# node-pty needs libuv headers on Linux
|
||||||
|
libuv,
|
||||||
|
}:
|
||||||
|
|
||||||
|
buildNpmPackage rec {
|
||||||
|
pname = "paseo";
|
||||||
|
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = ./..;
|
||||||
|
filter = path: type:
|
||||||
|
let
|
||||||
|
baseName = builtins.baseNameOf path;
|
||||||
|
relPath = lib.removePrefix (toString ./..) path;
|
||||||
|
in
|
||||||
|
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||||
|
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||||
|
# Exclude test fixtures and debug files
|
||||||
|
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||||
|
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||||
|
&& baseName != "node_modules"
|
||||||
|
&& baseName != ".git"
|
||||||
|
&& baseName != ".paseo"
|
||||||
|
&& baseName != ".DS_Store";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
|
||||||
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
|
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||||
|
|
||||||
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
# We manually rebuild only node-pty in buildPhase.
|
||||||
|
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
python3 # for node-gyp (node-pty compilation)
|
||||||
|
makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
libuv
|
||||||
|
];
|
||||||
|
|
||||||
|
# Don't use the default npm build hook — we need a custom build sequence
|
||||||
|
dontNpmBuild = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
# Rebuild only node-pty (native addon for terminal emulation).
|
||||||
|
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||||
|
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||||
|
# degrade when unavailable.
|
||||||
|
npm rebuild node-pty
|
||||||
|
|
||||||
|
# Build all daemon packages in dependency order (defined in package.json)
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out/lib/paseo
|
||||||
|
|
||||||
|
# Copy root package metadata
|
||||||
|
cp package.json $out/lib/paseo/
|
||||||
|
|
||||||
|
# Copy node_modules (preserving workspace symlinks)
|
||||||
|
cp -a node_modules $out/lib/paseo/
|
||||||
|
|
||||||
|
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||||
|
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||||
|
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||||
|
name=$(basename "$link")
|
||||||
|
if [ -d "packages/$name/dist" ]; then
|
||||||
|
mkdir -p "$out/lib/paseo/packages/$name"
|
||||||
|
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||||
|
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||||
|
if [ -d "packages/$name/node_modules" ]; then
|
||||||
|
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy CLI bin entry
|
||||||
|
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||||
|
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||||
|
|
||||||
|
# Copy extra server files referenced at runtime
|
||||||
|
for f in agent-prompt.md .env.example; do
|
||||||
|
if [ -f packages/server/$f ]; then
|
||||||
|
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||||
|
if [ -d packages/server/dist/scripts ]; then
|
||||||
|
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||||
|
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create wrapper for the server entry point (for systemd / direct use)
|
||||||
|
mkdir -p $out/bin
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||||
|
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||||
|
--set NODE_ENV production
|
||||||
|
|
||||||
|
# Create wrapper for the CLI
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||||
|
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||||
|
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||||
|
homepage = "https://github.com/getpaseo/paseo";
|
||||||
|
license = lib.licenses.agpl3Plus;
|
||||||
|
mainProgram = "paseo";
|
||||||
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+9335
-608
File diff suppressed because it is too large
Load Diff
+33
-17
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.26",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
"packages/expo-two-way-audio",
|
||||||
|
"packages/highlight",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
"packages/app",
|
"packages/app",
|
||||||
"packages/relay",
|
"packages/relay",
|
||||||
@@ -12,27 +14,30 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||||
|
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"format": "prettier --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "biome format .",
|
||||||
"start": "npm run start --workspace=@getpaseo/server",
|
"start": "npm run start --workspace=@getpaseo/server",
|
||||||
"android": "npm run android --workspace=@getpaseo/app",
|
"android": "npm run android --workspace=@getpaseo/app",
|
||||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||||
|
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||||
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
@@ -40,28 +45,31 @@
|
|||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.8",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
|
"react": "19.1.4",
|
||||||
|
"react-dom": "19.1.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -70,7 +78,14 @@
|
|||||||
"development",
|
"development",
|
||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
"author": "moboudra",
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
@@ -78,6 +93,7 @@
|
|||||||
"react-dom": "19.1.4"
|
"react-dom": "19.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ export default {
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
NSMicrophoneUsageDescription:
|
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||||
"This app needs access to the microphone for voice commands.",
|
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: variant.packageId,
|
bundleIdentifier: variant.packageId,
|
||||||
@@ -92,14 +91,15 @@ export default {
|
|||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
...(variant.googleServicesFile
|
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||||
? { googleServicesFile: variant.googleServicesFile }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "single",
|
output: "single",
|
||||||
favicon: "./assets/images/favicon.png",
|
favicon: "./assets/images/favicon.png",
|
||||||
},
|
},
|
||||||
|
autolinking: {
|
||||||
|
searchPaths: ["../../node_modules", "./node_modules"],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
@@ -143,6 +143,7 @@ export default {
|
|||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
autolinkingModuleResolution: true,
|
||||||
},
|
},
|
||||||
extra: {
|
extra: {
|
||||||
router: {},
|
router: {},
|
||||||
|
|||||||
Binary file not shown.
@@ -1,183 +0,0 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
connectDaemonClient,
|
|
||||||
createReplyTurn,
|
|
||||||
expectDetachedFromBottom,
|
|
||||||
expectNearBottom,
|
|
||||||
getChatContainerKey,
|
|
||||||
readScrollMetrics,
|
|
||||||
scrollUpFromBottom,
|
|
||||||
seedBottomAnchorAgent,
|
|
||||||
waitForAgentReady,
|
|
||||||
waitForContentGrowth,
|
|
||||||
} from "./helpers/agent-bottom-anchor";
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 180000 });
|
|
||||||
|
|
||||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
|
||||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
|
||||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
|
||||||
await tab.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-direct-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "commit" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-switch-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-new-agent").first().click();
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
|
||||||
turnCount: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1320, height: 920 });
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await composer.click();
|
|
||||||
for (let index = 0; index < 6; index += 1) {
|
|
||||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
|
||||||
if (index < 5) {
|
|
||||||
await page.keyboard.press("Shift+Enter");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await expectNearBottom(page);
|
|
||||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 820, height: 760 });
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await scrollUpFromBottom(page, 720);
|
|
||||||
await expectDetachedFromBottom(page);
|
|
||||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
|
||||||
|
|
||||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
|
||||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
|
||||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
|
||||||
const finish = await client.waitForFinish(agent.id, 120000);
|
|
||||||
expect(finish.status).toBe("idle");
|
|
||||||
await expectDetachedFromBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
(window as typeof window & {
|
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
|
||||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
|
||||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
|
||||||
(window as typeof window & {
|
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
|
||||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
|
||||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
|
||||||
});
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expect
|
|
||||||
.poll(async () => await getChatContainerKey(page))
|
|
||||||
.toBe("web-partial-virtualized");
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "commit" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expect
|
|
||||||
.poll(async () => await getChatContainerKey(page))
|
|
||||||
.toBe("web-partial-virtualized");
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 780, height: 720 });
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = "Respond with exactly: Hello";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, prompt);
|
|
||||||
|
|
||||||
await page.getByTestId("agent-overflow-menu").click();
|
|
||||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
|
||||||
"Not available"
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("agent-details-persistence-session-id-value")
|
|
||||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
|
||||||
|
|
||||||
await page.getByTestId("agent-details-agent-id").click();
|
|
||||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
const SERVER_ID =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
|
||||||
const AGENT_ID =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
|
||||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
|
||||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
|
||||||
const DAEMON_ENDPOINT =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
|
||||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
|
||||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
|
||||||
|
|
||||||
type ScrollMetrics = {
|
|
||||||
offsetY: number;
|
|
||||||
contentHeight: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
distanceFromBottom: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
test.use({ browserName: "firefox" });
|
|
||||||
|
|
||||||
function seedDaemonRegistryScript(params: {
|
|
||||||
serverId: string;
|
|
||||||
endpoint: string;
|
|
||||||
nowIso: string;
|
|
||||||
}) {
|
|
||||||
const daemon = {
|
|
||||||
serverId: params.serverId,
|
|
||||||
label: "localhost",
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:${params.endpoint}`,
|
|
||||||
type: "direct",
|
|
||||||
endpoint: params.endpoint,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${params.endpoint}`,
|
|
||||||
createdAt: params.nowIso,
|
|
||||||
updatedAt: params.nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
||||||
localStorage.setItem(
|
|
||||||
"@paseo:create-agent-preferences",
|
|
||||||
JSON.stringify({
|
|
||||||
serverId: params.serverId,
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: "haiku" },
|
|
||||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
|
||||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
|
||||||
const rootElement = root as HTMLElement;
|
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
|
||||||
const scrollElement =
|
|
||||||
candidates.find(
|
|
||||||
(element) =>
|
|
||||||
element instanceof HTMLElement &&
|
|
||||||
element.scrollHeight - element.clientHeight > 1
|
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
|
||||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
|
||||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
|
||||||
const distanceFromBottom = Math.max(
|
|
||||||
0,
|
|
||||||
contentHeight - (offsetY + viewportHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
offsetY,
|
|
||||||
contentHeight,
|
|
||||||
viewportHeight,
|
|
||||||
distanceFromBottom,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrollUpFromBottom(
|
|
||||||
page: Page,
|
|
||||||
pixels: number
|
|
||||||
): Promise<void> {
|
|
||||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
|
||||||
(root: Element, amount: number) => {
|
|
||||||
const rootElement = root as HTMLElement;
|
|
||||||
const candidates = [
|
|
||||||
rootElement,
|
|
||||||
...Array.from(rootElement.querySelectorAll("*")),
|
|
||||||
];
|
|
||||||
const scrollElement =
|
|
||||||
candidates.find(
|
|
||||||
(element) =>
|
|
||||||
element instanceof HTMLElement &&
|
|
||||||
element.scrollHeight - element.clientHeight > 1
|
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const bottomOffset = Math.max(
|
|
||||||
0,
|
|
||||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
|
||||||
);
|
|
||||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
|
||||||
},
|
|
||||||
pixels
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
||||||
expect(userAgent).toContain("Firefox");
|
|
||||||
|
|
||||||
await page.addInitScript(seedDaemonRegistryScript, {
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
endpoint: DAEMON_ENDPOINT,
|
|
||||||
nowIso: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
|
||||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Require enough history so the repro actually scrolls away from bottom.
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const metrics = await readScrollMetrics(page);
|
|
||||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(300);
|
|
||||||
|
|
||||||
await scrollUpFromBottom(page, 900);
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
const beforeSubmit = await readScrollMetrics(page);
|
|
||||||
const beforePath = testInfo.outputPath("before-submit.png");
|
|
||||||
await page.screenshot({ path: beforePath, fullPage: true });
|
|
||||||
await testInfo.attach("before-submit", {
|
|
||||||
path: beforePath,
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
|
||||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
|
||||||
await page.waitForTimeout(1200);
|
|
||||||
|
|
||||||
const afterSubmit = await readScrollMetrics(page);
|
|
||||||
const afterPath = testInfo.outputPath("after-submit.png");
|
|
||||||
await page.screenshot({ path: afterPath, fullPage: true });
|
|
||||||
await testInfo.attach("after-submit", {
|
|
||||||
path: afterPath,
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
await testInfo.attach("firefox-scroll-metrics", {
|
|
||||||
body: JSON.stringify(
|
|
||||||
{
|
|
||||||
url: AGENT_URL,
|
|
||||||
submitText: SUBMIT_TEXT,
|
|
||||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
|
||||||
beforeScreenshot: beforePath,
|
|
||||||
afterScreenshot: afterPath,
|
|
||||||
beforeSubmit,
|
|
||||||
afterSubmit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[firefox-scroll-repro] ${JSON.stringify(
|
|
||||||
{
|
|
||||||
submitText: SUBMIT_TEXT,
|
|
||||||
beforeSubmit,
|
|
||||||
afterSubmit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
|
||||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
|
||||||
NEAR_BOTTOM_THRESHOLD_PX
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, prompt);
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: 'commit' });
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
|
||||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { test, expect, type Page } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgent,
|
|
||||||
ensureHostSelected,
|
|
||||||
gotoHome,
|
|
||||||
setWorkingDirectory,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChangesScope(page: Page) {
|
|
||||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChangesHeader(page: Page) {
|
|
||||||
return getChangesScope(page).getByTestId('changes-header');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureExplorerTabsVisible(page: Page) {
|
|
||||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
|
||||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await toggle.click();
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
|
||||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
|
||||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
|
||||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await primaryBackdrop.click({ force: true });
|
|
||||||
await expect(primaryBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
|
||||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await overflowBackdrop.click({ force: true });
|
|
||||||
await expect(overflowBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
|
||||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await diffModeBackdrop.click({ force: true });
|
|
||||||
await expect(diffModeBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
|
||||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
|
||||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
|
||||||
if (current !== expected) {
|
|
||||||
await modeToggle.click();
|
|
||||||
const menu = page.getByTestId('changes-diff-status-menu');
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const optionTestId =
|
|
||||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
|
||||||
await page.getByTestId(optionTestId).click({ force: true });
|
|
||||||
}
|
|
||||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPrimaryMenu(page: Page) {
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
|
||||||
await expect(caret).toBeVisible();
|
|
||||||
await caret.click();
|
|
||||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
|
||||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
const changesHeader = getChangesHeader(page);
|
|
||||||
if (!(await changesHeader.isVisible())) {
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
|
||||||
if (options?.expectGit === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const changesScope = getChangesScope(page);
|
|
||||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
|
||||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
|
||||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAgentAndWait(page: Page, message: string) {
|
|
||||||
await createAgent(page, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
|
||||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
||||||
await trigger.click();
|
|
||||||
|
|
||||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
|
||||||
await searchInput.fill(branchName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredOption = menu
|
|
||||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
|
||||||
await preferredOption.click({ force: true });
|
|
||||||
await expect(menu).toHaveCount(0);
|
|
||||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enableCreateWorktree(page: Page) {
|
|
||||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
|
||||||
if (/Create new worktree/i.test(currentValue)) {
|
|
||||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await trigger.click();
|
|
||||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
|
||||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
|
||||||
await createOption.click({ force: true });
|
|
||||||
await expect(menu).toHaveCount(0);
|
|
||||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
|
||||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshUncommittedMode(page: Page) {
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChangesTab(page: Page) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
await page.getByTestId('explorer-tab-files').first().click();
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTmpPath(value: string) {
|
|
||||||
if (value.startsWith('/var/')) {
|
|
||||||
return `/private${value}`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GitWorktreeEntry = {
|
|
||||||
worktreePath: string;
|
|
||||||
branchRef: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
|
||||||
const blocks = raw
|
|
||||||
.split(/\n\s*\n/g)
|
|
||||||
.map((block) => block.trim())
|
|
||||||
.filter((block) => block.length > 0);
|
|
||||||
|
|
||||||
const entries: GitWorktreeEntry[] = [];
|
|
||||||
for (const block of blocks) {
|
|
||||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
|
||||||
if (!worktreeMatch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const branchMatch = block.match(/^branch (.+)$/m);
|
|
||||||
entries.push({
|
|
||||||
worktreePath: worktreeMatch[1].trim(),
|
|
||||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const output = execSync('git worktree list --porcelain', {
|
|
||||||
cwd: repoPath,
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
const entries = parseGitWorktreeList(output);
|
|
||||||
const candidate = entries.find((entry) => {
|
|
||||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
|
||||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!entry.branchRef) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !/\/main$/i.test(entry.branchRef);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (candidate) {
|
|
||||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
|
||||||
if (branchName) {
|
|
||||||
return {
|
|
||||||
worktreePath: candidate.worktreePath,
|
|
||||||
branchName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore transient git worktree read errors while polling.
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
|
||||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await enableCreateWorktree(page);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
|
||||||
await waitForAgentTurnToSettle(page);
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
|
||||||
.not.toBe('Unknown');
|
|
||||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
|
||||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
|
||||||
repo.path
|
|
||||||
);
|
|
||||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
|
||||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
|
||||||
realpath(firstCwd).catch(() => firstCwd),
|
|
||||||
realpath(repo.path).catch(() => repo.path),
|
|
||||||
]);
|
|
||||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
|
||||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
|
||||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
|
||||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
|
||||||
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await selectAttachWorktree(page, worktreeBranch);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
|
||||||
await waitForAgentTurnToSettle(page);
|
|
||||||
await openChangesPanel(page);
|
|
||||||
|
|
||||||
const readmePath = path.join(firstCwd, 'README.md');
|
|
||||||
await appendFile(readmePath, '\nFirst change\n');
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
|
||||||
await expect(page.getByText('First change')).toBeVisible();
|
|
||||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
|
||||||
await expect(primaryCta).toBeVisible();
|
|
||||||
await expect(primaryCta).toContainText('Commit');
|
|
||||||
|
|
||||||
await primaryCta.click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
return execSync('git status --porcelain', {
|
|
||||||
cwd: firstCwd,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe('');
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
|
||||||
await openChangesPrimaryMenu(page);
|
|
||||||
await page.getByTestId('changes-menu-push').click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
|
||||||
await writeFile(notesPath, 'Second change\n');
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
await refreshChangesTab(page);
|
|
||||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
return execSync('git status --porcelain', {
|
|
||||||
cwd: firstCwd,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe('');
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
|
||||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
|
||||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
|
||||||
await pushPrimary.click();
|
|
||||||
// Regression check: the primary CTA stays in place while pushing.
|
|
||||||
await expect(pushPrimary).toBeVisible();
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
await expect(pushPrimary).toBeVisible();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
const count = execSync(
|
|
||||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
|
||||||
{ cwd: firstCwd, encoding: 'utf8' }
|
|
||||||
).trim();
|
|
||||||
return Number.parseInt(count, 10);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe(0);
|
|
||||||
|
|
||||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
|
||||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
|
||||||
execSync("git checkout main", { cwd: repo.path });
|
|
||||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
|
||||||
execSync("git push", { cwd: repo.path });
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
|
||||||
'Committed',
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
await refreshChangesTab(page);
|
|
||||||
|
|
||||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
|
||||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
|
||||||
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
|
||||||
} finally {
|
|
||||||
await rm(nonGitDir, { recursive: true, force: true });
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgentInRepo } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('create agent in a temp repo', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = "Respond with exactly: Hello";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
|
||||||
|
|
||||||
// Verify user message is shown in the stream
|
|
||||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
|
||||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
|
||||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
|
||||||
|
|
||||||
// Verify the assistant response is rendered.
|
|
||||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('daemon is connected in settings', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
|
||||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('deleting an agent persists after reload', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const nonce = Math.random().toString(36).slice(2, 10);
|
|
||||||
const prompt = `respond-ready-${nonce}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
// Create agent (via message input) so it shows up in the sidebar list.
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.fill(prompt);
|
|
||||||
await input.press('Enter');
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
|
||||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
|
||||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
|
||||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
|
||||||
|
|
||||||
const match =
|
|
||||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
|
||||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
|
||||||
}
|
|
||||||
const serverId = decodeURIComponent(match[1]);
|
|
||||||
const agentId = decodeURIComponent(match[2]);
|
|
||||||
|
|
||||||
// Return home and delete via long-press in the agent list.
|
|
||||||
await gotoHome(page);
|
|
||||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
|
||||||
const agentRow = page.getByTestId(rowTestId).first();
|
|
||||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
|
||||||
await agentRow.hover();
|
|
||||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
|
||||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
|
||||||
await quickArchive.click({ force: true });
|
|
||||||
const confirmArchive = page
|
|
||||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
|
||||||
.first();
|
|
||||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
|
||||||
await confirmArchive.click({ force: true });
|
|
||||||
|
|
||||||
// Ensure deletion finished before reload (avoids races).
|
|
||||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
|
||||||
|
|
||||||
// A full reload should not bring the agent back.
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
|
||||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgent,
|
|
||||||
createAgentInRepo,
|
|
||||||
ensureHostSelected,
|
|
||||||
gotoHome,
|
|
||||||
openSettings,
|
|
||||||
setWorkingDirectory,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {
|
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from '@/utils/host-routes';
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from './helpers/workspace-tabs';
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFakeMicrophone(page: Page) {
|
|
||||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
|
||||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
|
||||||
const mimeType = 'audio/wav';
|
|
||||||
|
|
||||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
|
||||||
const mic = {
|
|
||||||
active: 0,
|
|
||||||
getUserMediaCalls: 0,
|
|
||||||
stopCalls: 0,
|
|
||||||
lastRecorder: null as null | { state: string },
|
|
||||||
};
|
|
||||||
(window as any).__mic = mic;
|
|
||||||
|
|
||||||
(window as any).isSecureContext = true;
|
|
||||||
|
|
||||||
const nav = navigator as any;
|
|
||||||
if (!nav.mediaDevices) {
|
|
||||||
nav.mediaDevices = {};
|
|
||||||
}
|
|
||||||
nav.mediaDevices.getUserMedia = async () => {
|
|
||||||
mic.getUserMediaCalls += 1;
|
|
||||||
mic.active += 1;
|
|
||||||
const track = {
|
|
||||||
stop: () => {
|
|
||||||
mic.stopCalls += 1;
|
|
||||||
mic.active = Math.max(0, mic.active - 1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
getTracks: () => [track],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const AudioContextCtor =
|
|
||||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
|
||||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
|
||||||
const nativeCreateMediaStreamSource =
|
|
||||||
AudioContextCtor.prototype.createMediaStreamSource;
|
|
||||||
AudioContextCtor.prototype.createMediaStreamSource =
|
|
||||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
|
||||||
const isFakeStream =
|
|
||||||
!!stream &&
|
|
||||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
|
||||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
|
||||||
if (isFakeStream) {
|
|
||||||
throw new Error('Force recorder fallback for fake microphone stream');
|
|
||||||
}
|
|
||||||
return nativeCreateMediaStreamSource.call(this, stream);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return new Blob([bytes], { type: mimeType });
|
|
||||||
};
|
|
||||||
|
|
||||||
class FakeMediaRecorder extends EventTarget {
|
|
||||||
public static isTypeSupported() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public state: 'inactive' | 'recording' = 'inactive';
|
|
||||||
public mimeType: string;
|
|
||||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
|
||||||
public onerror: ((event: unknown) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
|
||||||
super();
|
|
||||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
|
||||||
mic.lastRecorder = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public start() {
|
|
||||||
this.state = 'recording';
|
|
||||||
}
|
|
||||||
|
|
||||||
public stop() {
|
|
||||||
if (this.state !== 'recording') {
|
|
||||||
throw new Error('Not recording');
|
|
||||||
}
|
|
||||||
this.state = 'inactive';
|
|
||||||
try {
|
|
||||||
this.ondataavailable?.({
|
|
||||||
data: blobFromBase64(base64Audio, mimeType),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.onerror?.(err);
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new Event('stop'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
|
||||||
}, { base64Audio, mimeType });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectComposerReady(page: Page) {
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDictationStarted(page: Page) {
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDictationStopped(page: Page) {
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
prompt: 'Respond with exactly: Hello',
|
|
||||||
});
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
prompt: 'Respond with exactly: Hello',
|
|
||||||
});
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await openSettings(page);
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
|
||||||
|
|
||||||
expect(calls).toBe(0);
|
|
||||||
expect(active).toBe(0);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const initialCopyMessageCount = await page
|
|
||||||
.getByRole('button', { name: 'Copy message' })
|
|
||||||
.count();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
|
||||||
{ timeout: 60_000 }
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(initialCopyMessageCount);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
|
||||||
if (mic.lastRecorder) {
|
|
||||||
mic.lastRecorder.state = 'inactive';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
|
||||||
}
|
|
||||||
const serverId = decodeURIComponent(match[1]!);
|
|
||||||
const agentId = decodeURIComponent(match[2]!);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
|
||||||
await expect(newAgentButton).toBeVisible();
|
|
||||||
await newAgentButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
|
||||||
|
|
||||||
await page.waitForTimeout(10_000);
|
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
|
||||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
|
||||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await newAgentButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole("button", {
|
|
||||||
name: /open explorer|close explorer|toggle explorer/i,
|
|
||||||
})
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
|
||||||
).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const terminalsTab = page
|
|
||||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
|
||||||
.first();
|
|
||||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
|
||||||
await terminalsTab.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
|
||||||
).toBeVisible({ timeout: 30000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { test as base, expect, type Page } from '@playwright/test';
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
|
|
||||||
// Extend base test to provide dynamic baseURL from global-setup
|
// Extend base test to provide dynamic baseURL from global-setup
|
||||||
const test = base.extend({
|
const test = base.extend({
|
||||||
baseURL: async ({}, use) => {
|
baseURL: async ({}, use) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
|
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||||
}
|
}
|
||||||
await use(`http://localhost:${metroPort}`);
|
await use(`http://localhost:${metroPort}`);
|
||||||
},
|
},
|
||||||
@@ -18,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!daemonPort) {
|
if (!daemonPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
|
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||||
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
|
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (daemonPort === '6767') {
|
if (daemonPort === "6767") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||||
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
|
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
|
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,17 +39,17 @@ test.beforeEach(async ({ page }) => {
|
|||||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
consoleEntries.set(page, entries);
|
consoleEntries.set(page, entries);
|
||||||
|
|
||||||
page.on('console', (message) => {
|
page.on("console", (message) => {
|
||||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on('pageerror', (error) => {
|
page.on("pageerror", (error) => {
|
||||||
entries.push(`[pageerror] ${error.message}`);
|
entries.push(`[pageerror] ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,40 +57,21 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const seedNonce = Math.random().toString(36).slice(2);
|
const seedNonce = Math.random().toString(36).slice(2);
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||||
}
|
}
|
||||||
const testDaemon = {
|
const testDaemon = buildSeededHost({
|
||||||
serverId,
|
serverId,
|
||||||
label: 'localhost',
|
endpoint: `127.0.0.1:${daemonPort}`,
|
||||||
connections: [
|
nowIso,
|
||||||
{
|
});
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAgentPreferences = {
|
|
||||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
|
||||||
serverId: testDaemon.serverId,
|
|
||||||
// Keep e2e fast/cheap by default.
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce }) => {
|
({ daemon, preferences, seedNonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
if (disableValue) {
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
@@ -98,15 +80,15 @@ test.beforeEach(async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await testInfo.attach('browser-console', {
|
await testInfo.attach("browser-console", {
|
||||||
body: entries.join('\n'),
|
body: entries.join("\n"),
|
||||||
contentType: 'text/plain',
|
contentType: "text/plain",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { appendFile } from 'node:fs/promises';
|
|
||||||
import { test, expect, type Page } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 90000 });
|
|
||||||
|
|
||||||
function getChangesScope(page: Page) {
|
|
||||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureExplorerTabsVisible(page: Page) {
|
|
||||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
|
||||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await toggle.click();
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPanel(page: Page) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
|
||||||
if (!(await changesHeader.isVisible())) {
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
await expect(changesHeader).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshUncommittedMode(page: Page) {
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
|
||||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await diffModeBackdrop.click({ force: true });
|
|
||||||
await expect(diffModeBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentLabel = (await toggle.innerText()).trim();
|
|
||||||
await toggle.click({ force: true });
|
|
||||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
|
||||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
|
||||||
await page.getByTestId(firstTarget).click({ force: true });
|
|
||||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
|
||||||
|
|
||||||
const nextLabel = (await toggle.innerText()).trim();
|
|
||||||
await toggle.click({ force: true });
|
|
||||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
|
||||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
|
||||||
await page.getByTestId(secondTarget).click({ force: true });
|
|
||||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAgentAndWait(page: Page, message: string) {
|
|
||||||
await createAgent(page, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
|
|
||||||
const readmePath = path.join(repo.path, 'README.md');
|
|
||||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
|
||||||
await appendFile(readmePath, `\n${lines}`);
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
|
||||||
await fileToggle.click();
|
|
||||||
|
|
||||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
|
||||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
|
||||||
await expect(scroll).toBeVisible();
|
|
||||||
|
|
||||||
await expect.poll(async () => {
|
|
||||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
|
||||||
}).toBe(true);
|
|
||||||
|
|
||||||
await scroll.hover();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
await page.mouse.wheel(0, 700);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect.poll(async () => {
|
|
||||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
|
||||||
}).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(fileToggle).toBeVisible();
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
|
||||||
const prompt = `global draft prompt ${Date.now()}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
|
|
||||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
|
||||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
|
||||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
|
||||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
|
|
||||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
|
||||||
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(prompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
|
||||||
|
|
||||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
|
||||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
|
||||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import type { Page } from '@playwright/test'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
|
||||||
|
|
||||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
|
||||||
const url = new URL(rawUrl)
|
|
||||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
|
||||||
const workspaceToken = pathMatch?.[1] ?? null
|
|
||||||
let agentId = pathMatch?.[2] ?? null
|
|
||||||
|
|
||||||
if (!agentId) {
|
|
||||||
const open = url.searchParams.get('open')
|
|
||||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
|
||||||
if (openMatch?.[1]) {
|
|
||||||
agentId = openMatch[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workspaceToken, agentId }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
|
||||||
const url = new URL(rawUrl)
|
|
||||||
const open = url.searchParams.get('open')
|
|
||||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
|
||||||
return openMatch?.[1] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
|
||||||
await page.keyboard.press('Escape').catch(() => undefined)
|
|
||||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await thinkingTrigger.click({ force: true })
|
|
||||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
|
||||||
if (!(await menu.isVisible().catch(() => false))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
|
||||||
for (const label of preferred) {
|
|
||||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
|
||||||
if (await option.isVisible().catch(() => false)) {
|
|
||||||
await option.click({ force: true })
|
|
||||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = menu.getByRole('button')
|
|
||||||
const count = await options.count()
|
|
||||||
if (count > 0) {
|
|
||||||
await options.last().click({ force: true })
|
|
||||||
}
|
|
||||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
|
||||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
|
||||||
const count = await candidates.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
|
||||||
if (await isAnyStopVisible(page)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
|
||||||
const count = await interruptSendButton.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
|
||||||
const locator = page.getByText(matcher).first()
|
|
||||||
const matches = page.getByText(matcher)
|
|
||||||
const count = await matches.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await locator.isVisible().catch(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(360_000)
|
|
||||||
const serverId = process.env.E2E_SERVER_ID
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
|
||||||
const id = `${Date.now()}`
|
|
||||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
|
||||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
|
||||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
|
||||||
const seedToken = `ACK_SEED_${id}`
|
|
||||||
const secondToken = `ACK_SECOND_${id}`
|
|
||||||
expect(seedPrompt).not.toBe(secondPrompt)
|
|
||||||
expect(seedToken).not.toBe(secondToken)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
await page.goto(`/h/${serverId}/new-agent`)
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 1) Seed workspace with an existing agent.
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await seedComposer.fill(seedPrompt)
|
|
||||||
await seedComposer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
|
||||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
|
||||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
|
||||||
}
|
|
||||||
const seededAgentId = seededContext.agentId
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
|
||||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
|
||||||
|
|
||||||
// 2) Use global New Agent entry and 3) select same workspace.
|
|
||||||
await page.getByText('New agent', { exact: true }).first().click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
await preferSlowerThinkingOption(page)
|
|
||||||
await page.keyboard.press('Escape').catch(() => undefined)
|
|
||||||
|
|
||||||
// 4) Create second agent from global draft.
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(secondPrompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
if (page.url().includes('/new-agent')) {
|
|
||||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
|
||||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
|
||||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
|
||||||
await sendButton.click({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
|
||||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
|
||||||
if (!createdContext.agentId) {
|
|
||||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
|
||||||
}
|
|
||||||
const createdAgentId = createdContext.agentId
|
|
||||||
expect(createdAgentId).toBeTruthy()
|
|
||||||
expect(createdAgentId).not.toBe(seededAgentId)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
|
||||||
|
|
||||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
|
||||||
return finalTabIds.includes(createdTabTestId)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
|
||||||
|
|
||||||
// 6) Response assertions are scoped to created-agent active context.
|
|
||||||
await expect
|
|
||||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
|
||||||
.toBe(true)
|
|
||||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
|
||||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await lifecycleComposer.fill(lifecyclePrompt)
|
|
||||||
await lifecycleComposer.press('Enter')
|
|
||||||
await expect
|
|
||||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
let sawRunning = false
|
|
||||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
|
||||||
if (await isRunningControlVisible(page)) {
|
|
||||||
sawRunning = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(50)
|
|
||||||
}
|
|
||||||
expect(sawRunning).toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
|
||||||
.toBe(true)
|
|
||||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+143
-103
@@ -1,11 +1,11 @@
|
|||||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import net from 'node:net';
|
import net from "node:net";
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
|
|||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.once('error', reject);
|
server.once("error", reject);
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
if (!address || typeof address === 'string') {
|
if (!address || typeof address === "string") {
|
||||||
server.close(() => reject(new Error('Failed to acquire port')));
|
server.close(() => reject(new Error("Failed to acquire port")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
server.close(() => resolve(address.port));
|
server.close(() => resolve(address.port));
|
||||||
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dump() {
|
dump() {
|
||||||
return lines.join('\n');
|
return lines.join("\n");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||||
if (!getRecentOutput) {
|
if (!getRecentOutput) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
const output = getRecentOutput().trim();
|
const output = getRecentOutput().trim();
|
||||||
if (!output) {
|
if (!output) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
return `\nRecent output:\n${output}`;
|
return `\nRecent output:\n${output}`;
|
||||||
}
|
}
|
||||||
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||||
const {
|
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||||
host = '127.0.0.1',
|
|
||||||
timeoutMs = 15000,
|
|
||||||
label,
|
|
||||||
childProcess,
|
|
||||||
getRecentOutput,
|
|
||||||
} = options;
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (childProcess && childProcess.exitCode !== null) {
|
if (childProcess && childProcess.exitCode !== null) {
|
||||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||||
});
|
});
|
||||||
socket.on('error', reject);
|
socket.on("error", reject);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason =
|
const reason =
|
||||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
lastConnectionError instanceof Error
|
||||||
|
? ` Last connection error: ${lastConnectionError.message}`
|
||||||
|
: "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
child.kill('SIGTERM');
|
child.kill("SIGTERM");
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
child.kill('SIGKILL');
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
child.once('exit', () => {
|
child.once("exit", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
function summarizeOpenAiErrorBody(body: string): string {
|
function summarizeOpenAiErrorBody(body: string): string {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return 'empty response body';
|
return "empty response body";
|
||||||
}
|
}
|
||||||
if (trimmed.length <= 240) {
|
if (trimmed.length <= 240) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
},
|
},
|
||||||
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -196,28 +192,42 @@ type OfferPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function stripAnsi(input: string): string {
|
function stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||||
|
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
|
||||||
|
if (existsSync(relayDistEntry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||||
|
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||||
const marker = '#offer=';
|
const marker = "#offer=";
|
||||||
const idx = url.indexOf(marker);
|
const idx = url.indexOf(marker);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||||
}
|
}
|
||||||
const encoded = url.slice(idx + marker.length);
|
const encoded = url.slice(idx + marker.length);
|
||||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||||
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||||
return offer as OfferPayload;
|
return offer as OfferPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
const repoRoot = path.resolve(__dirname, '../../..');
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
const envTestPath = path.join(repoRoot, '.env.test');
|
ensureRelayBuildArtifact(repoRoot);
|
||||||
|
const envTestPath = path.join(repoRoot, ".env.test");
|
||||||
if (existsSync(envTestPath)) {
|
if (existsSync(envTestPath)) {
|
||||||
dotenv.config({ path: envTestPath });
|
dotenv.config({ path: envTestPath });
|
||||||
}
|
}
|
||||||
@@ -225,13 +235,17 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
const metroPort = await getAvailablePort();
|
||||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
await Promise.all([
|
||||||
|
stopProcess(daemonProcess),
|
||||||
|
stopProcess(metroProcess),
|
||||||
|
stopProcess(relayProcess),
|
||||||
|
]);
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
@@ -242,24 +256,30 @@ export default async function globalSetup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
const defaultLocalModelsDir = path.join(
|
||||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
process.env.HOME ?? "",
|
||||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
".paseo",
|
||||||
|
"models",
|
||||||
|
"local-speech",
|
||||||
|
);
|
||||||
|
const hasDefaultLocalModelsDir =
|
||||||
|
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||||
|
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||||
|
|
||||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||||
console.log(
|
console.log(
|
||||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
const maxRelayStartupAttempts = 5;
|
const maxRelayStartupAttempts = 5;
|
||||||
let relayStarted = false;
|
let relayStarted = false;
|
||||||
let lastRelayStartupError: unknown = null;
|
let lastRelayStartupError: unknown = null;
|
||||||
@@ -271,18 +291,21 @@ export default async function globalSetup() {
|
|||||||
let relayReadyForSelectedPort = false;
|
let relayReadyForSelectedPort = false;
|
||||||
|
|
||||||
relayProcess = spawn(
|
relayProcess = spawn(
|
||||||
'npx',
|
"npx",
|
||||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||||
{
|
{
|
||||||
cwd: relayDir,
|
cwd: relayDir,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stdout] ${line}`);
|
relayLineBuffer.add(`[stdout] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -297,8 +320,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[relay] ${line}`);
|
console.log(`[relay] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stderr] ${line}`);
|
relayLineBuffer.add(`[stderr] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -316,7 +342,7 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServer(relayPort, {
|
await waitForServer(relayPort, {
|
||||||
label: 'Relay dev server',
|
label: "Relay dev server",
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
childProcess: relayProcess,
|
childProcess: relayProcess,
|
||||||
getRecentOutput: relayLineBuffer.dump,
|
getRecentOutput: relayLineBuffer.dump,
|
||||||
@@ -339,15 +365,15 @@ export default async function globalSetup() {
|
|||||||
if (!relayReadyForSelectedPort) {
|
if (!relayReadyForSelectedPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||||
relayLineBuffer.dump
|
relayLineBuffer.dump,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||||
relayLineBuffer.dump
|
relayLineBuffer.dump,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,40 +392,46 @@ export default async function globalSetup() {
|
|||||||
? lastRelayStartupError.message
|
? lastRelayStartupError.message
|
||||||
: String(lastRelayStartupError);
|
: String(lastRelayStartupError);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Metro bundler on dynamic port
|
// Start Metro bundler on dynamic port
|
||||||
const appDir = path.resolve(__dirname, '..');
|
const appDir = path.resolve(__dirname, "..");
|
||||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BROWSER: 'none', // Don't auto-open browser
|
BROWSER: "none", // Don't auto-open browser
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stdout] ${line}`);
|
metroLineBuffer.add(`[stdout] ${line}`);
|
||||||
console.log(`[metro] ${line}`);
|
console.log(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stderr] ${line}`);
|
metroLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[metro] ${line}`);
|
console.error(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||||
const tsxBin = execSync('which tsx').toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
|
|
||||||
let offerPayload: OfferPayload | null = null;
|
let offerPayload: OfferPayload | null = null;
|
||||||
let offerResolve: (() => void) | null = null;
|
let offerResolve: (() => void) | null = null;
|
||||||
@@ -407,33 +439,36 @@ export default async function globalSetup() {
|
|||||||
offerResolve = resolve;
|
offerResolve = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASEO_HOME: paseoHome,
|
PASEO_HOME: paseoHome,
|
||||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_DICTATION_ENABLED: '1',
|
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_VOICE_MODE_ENABLED: '1',
|
...(openAiUsable
|
||||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
? {
|
||||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: "development",
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdoutBuffer = '';
|
let stdoutBuffer = "";
|
||||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
stdoutBuffer += data.toString('utf8');
|
stdoutBuffer += data.toString("utf8");
|
||||||
const lines = stdoutBuffer.split('\n');
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() ?? '';
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
@@ -442,13 +477,13 @@ export default async function globalSetup() {
|
|||||||
const clean = stripAnsi(trimmed);
|
const clean = stripAnsi(trimmed);
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||||
if (match && clean.includes('pairing_offer')) {
|
if (match && clean.includes("pairing_offer")) {
|
||||||
try {
|
try {
|
||||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
@@ -462,8 +497,11 @@ export default async function globalSetup() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[daemon] ${line}`);
|
console.error(`[daemon] ${line}`);
|
||||||
@@ -473,12 +511,12 @@ export default async function globalSetup() {
|
|||||||
// Wait for both daemon and Metro to be ready
|
// Wait for both daemon and Metro to be ready
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
waitForServer(port, {
|
waitForServer(port, {
|
||||||
label: 'Paseo daemon',
|
label: "Paseo daemon",
|
||||||
childProcess: daemonProcess,
|
childProcess: daemonProcess,
|
||||||
getRecentOutput: daemonLineBuffer.dump,
|
getRecentOutput: daemonLineBuffer.dump,
|
||||||
}),
|
}),
|
||||||
waitForServer(metroPort, {
|
waitForServer(metroPort, {
|
||||||
label: 'Metro web server',
|
label: "Metro web server",
|
||||||
timeoutMs: 120000, // Metro can take longer to start
|
timeoutMs: 120000, // Metro can take longer to start
|
||||||
childProcess: metroProcess,
|
childProcess: metroProcess,
|
||||||
getRecentOutput: metroLineBuffer.dump,
|
getRecentOutput: metroLineBuffer.dump,
|
||||||
@@ -489,11 +527,11 @@ export default async function globalSetup() {
|
|||||||
await Promise.race([
|
await Promise.race([
|
||||||
offerPromise,
|
offerPromise,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
if (!offerPayload) {
|
if (!offerPayload) {
|
||||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||||
}
|
}
|
||||||
const offer = offerPayload as OfferPayload;
|
const offer = offerPayload as OfferPayload;
|
||||||
|
|
||||||
@@ -502,11 +540,13 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
process.env.E2E_METRO_PORT = String(metroPort);
|
||||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
console.log(
|
||||||
|
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||||
|
);
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
console.log('[e2e] Test daemon stopped');
|
console.log("[e2e] Test daemon stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "../../src/utils/host-routes";
|
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
|
|
||||||
@@ -37,10 +34,7 @@ export type DaemonClientInstance = {
|
|||||||
initialPrompt: string;
|
initialPrompt: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
waitForFinish(
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
agentId: string,
|
|
||||||
timeout?: number
|
|
||||||
): Promise<{ status: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
function getDaemonWsUrl(): string {
|
||||||
@@ -66,17 +60,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
|
|||||||
}).join("\n");
|
}).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProtocolMessage(label: string): string {
|
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||||
return [
|
return [
|
||||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||||
"REPLY:",
|
"REPLY:",
|
||||||
buildReplyBlock(label),
|
buildReplyBlock(label, lineCount),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplyMessage(label: string): string {
|
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplyTurn(label: string): {
|
export function createReplyTurn(label: string): {
|
||||||
@@ -89,14 +83,16 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<new (config: {
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
url: string;
|
new (config: {
|
||||||
clientId: string;
|
url: string;
|
||||||
clientType: "cli";
|
clientId: string;
|
||||||
}) => DaemonClientInstance> {
|
clientType: "cli";
|
||||||
|
}) => DaemonClientInstance
|
||||||
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: {
|
||||||
@@ -124,9 +120,11 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
turnCount?: number;
|
turnCount?: number;
|
||||||
|
lineCount?: number;
|
||||||
}): Promise<SeededAgent> {
|
}): Promise<SeededAgent> {
|
||||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||||
|
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||||
const created = await input.client.createAgent({
|
const created = await input.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.1-codex-mini",
|
model: "gpt-5.1-codex-mini",
|
||||||
@@ -134,24 +132,24 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
title,
|
title,
|
||||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||||
});
|
});
|
||||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (initialFinish.status !== "idle") {
|
if (initialFinish.status !== "idle") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
|
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||||
for (let index = 1; index < turnCount; index += 1) {
|
for (let index = 1; index < turnCount; index += 1) {
|
||||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||||
expectedTailText = buildReplyBlock(label);
|
expectedTailText = buildReplyBlock(label, lineCount);
|
||||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (finish.status !== "idle") {
|
if (finish.status !== "idle") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
|
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,29 +158,37 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
id: created.id,
|
id: created.id,
|
||||||
title,
|
title,
|
||||||
expectedTailText,
|
expectedTailText,
|
||||||
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
|
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleChatScroll(page: Page) {
|
||||||
|
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
|
}
|
||||||
|
|
||||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||||
const rootElement = root as HTMLElement;
|
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||||
|
.filter((element) => {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const isEditable =
|
||||||
|
tagName === "textarea" ||
|
||||||
|
tagName === "input" ||
|
||||||
|
element.getAttribute("contenteditable") === "true";
|
||||||
|
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||||
|
});
|
||||||
const scrollElement =
|
const scrollElement =
|
||||||
candidates.find(
|
candidates.sort(
|
||||||
(element) =>
|
(left, right) =>
|
||||||
element instanceof HTMLElement &&
|
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
|
||||||
element.scrollHeight - element.clientHeight > 1
|
)[0] ?? (root as HTMLElement);
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||||
const distanceFromBottom = Math.max(
|
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||||
0,
|
|
||||||
contentHeight - (offsetY + viewportHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
offsetY,
|
offsetY,
|
||||||
@@ -194,29 +200,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
const scrollViewport = getVisibleChatScroll(page);
|
||||||
(root: Element, amount: number) => {
|
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||||
const rootElement = root as HTMLElement;
|
let remaining = Math.max(0, pixels);
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
while (remaining > 0) {
|
||||||
const scrollElement =
|
const delta = Math.min(240, remaining);
|
||||||
candidates.find(
|
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||||
(element) =>
|
const scrollContainer = element as HTMLElement;
|
||||||
element instanceof HTMLElement &&
|
scrollContainer.dispatchEvent(
|
||||||
element.scrollHeight - element.clientHeight > 1
|
new WheelEvent("wheel", {
|
||||||
) ?? rootElement;
|
deltaY: -step,
|
||||||
|
bubbles: true,
|
||||||
const bottomOffset = Math.max(
|
cancelable: true,
|
||||||
0,
|
}),
|
||||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
|
||||||
);
|
);
|
||||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||||
},
|
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||||
pixels
|
}, delta);
|
||||||
);
|
remaining -= delta;
|
||||||
|
|
||||||
|
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
@@ -251,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForContentGrowth(
|
export async function waitForContentGrowth(
|
||||||
page: Page,
|
page: Page,
|
||||||
previousContentHeight: number
|
previousContentHeight: number,
|
||||||
): Promise<ScrollMetrics> {
|
): Promise<ScrollMetrics> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -263,11 +273,9 @@ export async function waitForContentGrowth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||||
return page
|
return getVisibleChatScroll(page).evaluate((element) => {
|
||||||
.getByTestId("agent-chat-scroll")
|
const nativeId = (element as HTMLElement).id;
|
||||||
.evaluate((element) => {
|
const prefix = "agent-chat-scroll-";
|
||||||
const nativeId = (element as HTMLElement).id;
|
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||||
const prefix = "agent-chat-scroll-";
|
});
|
||||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-173
@@ -1,16 +1,19 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
function getE2EDaemonPort(): string {
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
if (!port) {
|
if (!port) {
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
if (port === '6767') {
|
if (port === "6767") {
|
||||||
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
|
throw new Error(
|
||||||
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
@@ -20,67 +23,58 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
if (!expectedServerId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
|
const needsReset = await page.evaluate(
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
({ expectedEndpoint, expectedServerId }) => {
|
||||||
if (!raw) return true;
|
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
try {
|
if (!raw) return true;
|
||||||
const parsed = JSON.parse(raw);
|
try {
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
const parsed = JSON.parse(raw);
|
||||||
const entry = parsed[0] as any;
|
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||||
if (entry?.serverId !== expectedServerId) return true;
|
const entry = parsed[0] as any;
|
||||||
const connections = entry?.connections;
|
if (entry?.serverId !== expectedServerId) return true;
|
||||||
if (!Array.isArray(connections)) return true;
|
const connections = entry?.connections;
|
||||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
if (!Array.isArray(connections)) return true;
|
||||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
if (
|
||||||
} catch {
|
connections.some(
|
||||||
return true;
|
(c: any) =>
|
||||||
}
|
c?.type === "directTcp" &&
|
||||||
}, { expectedEndpoint, expectedServerId });
|
typeof c?.endpoint === "string" &&
|
||||||
|
/:6767\b/.test(c.endpoint),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return !connections.some(
|
||||||
|
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ expectedEndpoint, expectedServerId },
|
||||||
|
);
|
||||||
|
|
||||||
if (!needsReset) {
|
if (!needsReset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
const daemon = buildSeededHost({
|
||||||
|
serverId: expectedServerId,
|
||||||
|
endpoint: expectedEndpoint,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
({ daemon, preferences }) => {
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem(
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
'@paseo:daemon-registry',
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
JSON.stringify([
|
localStorage.removeItem("@paseo:settings");
|
||||||
{
|
|
||||||
serverId: expectedServerId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:${expectedEndpoint}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: expectedEndpoint,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
);
|
|
||||||
localStorage.setItem(
|
|
||||||
'@paseo:create-agent-preferences',
|
|
||||||
JSON.stringify({
|
|
||||||
serverId: expectedServerId,
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
},
|
||||||
{ expectedEndpoint, nowIso, expectedServerId }
|
{ daemon, preferences },
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -91,77 +85,116 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
if (!expectedServerId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await page.evaluate(() => {
|
const snapshot = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
return { registryRaw, prefsRaw };
|
return { registryRaw, prefsRaw };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snapshot.registryRaw) {
|
if (!snapshot.registryRaw) {
|
||||||
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
|
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry: any;
|
let registry: any;
|
||||||
try {
|
try {
|
||||||
registry = JSON.parse(snapshot.registryRaw);
|
registry = JSON.parse(snapshot.registryRaw);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
|
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
|
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemon = registry[0];
|
const daemon = registry[0];
|
||||||
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
|
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||||
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (daemon.serverId !== expectedServerId) {
|
if (daemon.serverId !== expectedServerId) {
|
||||||
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections: unknown = daemon?.connections;
|
const connections: unknown = daemon?.connections;
|
||||||
if (
|
if (
|
||||||
!Array.isArray(connections) ||
|
!Array.isArray(connections) ||
|
||||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
if (
|
||||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
Array.isArray(connections) &&
|
||||||
|
connections.some(
|
||||||
|
(c: any) =>
|
||||||
|
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!snapshot.prefsRaw) {
|
if (!snapshot.prefsRaw) {
|
||||||
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
|
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||||
if (prefs?.serverId !== daemon.serverId) {
|
if (prefs?.serverId !== daemon.serverId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
|
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) throw error;
|
if (error instanceof Error) throw error;
|
||||||
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
|
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gotoHome = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
await ensureE2EStorageSeeded(page);
|
await ensureE2EStorageSeeded(page);
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
};
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
export const gotoHome = async (page: Page) => {
|
||||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
await gotoAppShell(page);
|
||||||
await newAgentButton.click();
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
if (
|
||||||
|
!(await composer
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||||
|
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||||
|
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||||
|
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||||
|
(await newAgentButton.isVisible().catch(() => false)),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||||
|
await addProjectCta.click();
|
||||||
|
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||||
|
await addProjectSidebar.click();
|
||||||
|
} else {
|
||||||
|
await newAgentButton.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||||
};
|
};
|
||||||
@@ -169,7 +202,7 @@ export const gotoHome = async (page: Page) => {
|
|||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||||
@@ -185,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||||
const closeBottomSheet = async () => {
|
const closeBottomSheet = async () => {
|
||||||
const bottomSheetBackdrop = page
|
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||||
.first();
|
|
||||||
const bottomSheetHandle = page
|
|
||||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
|
||||||
.first();
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bottomSheetBackdrop.click({ force: true });
|
await bottomSheetBackdrop.click({ force: true });
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
if (await bottomSheetBackdrop.isVisible()) {
|
if (await bottomSheetBackdrop.isVisible()) {
|
||||||
@@ -221,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||||
if (await attachToggle.isVisible()) {
|
if (await attachToggle.isVisible()) {
|
||||||
await attachToggle.click({ force: true });
|
await attachToggle.click({ force: true });
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -242,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectorySelect.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
}
|
}
|
||||||
await expect
|
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||||
const activeInput =
|
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||||
(await directorySearchInput.isVisible().catch(() => false))
|
? directorySearchInput
|
||||||
? directorySearchInput
|
: legacyInput;
|
||||||
: legacyInput;
|
|
||||||
|
|
||||||
await activeInput.fill(trimmedDirectory);
|
await activeInput.fill(trimmedDirectory);
|
||||||
|
|
||||||
if (activeInput === directorySearchInput) {
|
if (activeInput === directorySearchInput) {
|
||||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||||
const plainOption = page
|
const plainOption = page
|
||||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const prefixedUseOption = page
|
const prefixedUseOption = page
|
||||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (await plainOption.isVisible().catch(() => false)) {
|
if (await plainOption.isVisible().catch(() => false)) {
|
||||||
@@ -270,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await prefixedUseOption.click({ force: true });
|
await prefixedUseOption.click({ force: true });
|
||||||
} else {
|
} else {
|
||||||
// Fallback: accept highlighted option (directory suggestion).
|
// Fallback: accept highlighted option (directory suggestion).
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy path picker fallback.
|
// Legacy path picker fallback.
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for picker to close.
|
// Wait for picker to close.
|
||||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||||
if (trimmedDirectory.startsWith('/var/')) {
|
if (trimmedDirectory.startsWith("/var/")) {
|
||||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||||
}
|
}
|
||||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
|
||||||
|
|
||||||
await expect.poll(async () => {
|
await expect
|
||||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
.poll(
|
||||||
if (text.includes(basename)) return true;
|
async () => {
|
||||||
for (const candidate of directoryCandidates) {
|
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||||
if (text.includes(candidate)) return true;
|
if (text.includes(basename)) return true;
|
||||||
}
|
for (const candidate of directoryCandidates) {
|
||||||
return false;
|
if (text.includes(candidate)) return true;
|
||||||
}, { timeout: 30000 }).toBe(true);
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
@@ -314,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fix = await page.evaluate(() => {
|
const fix = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
|
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||||
const registry = JSON.parse(registryRaw) as any[];
|
const registry = JSON.parse(registryRaw) as any[];
|
||||||
const prefs = JSON.parse(prefsRaw) as any;
|
const prefs = JSON.parse(prefsRaw) as any;
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
|
if (!Array.isArray(registry) || registry.length !== 1)
|
||||||
|
return { ok: false, reason: "registry shape" } as const;
|
||||||
const serverId = registry[0]?.serverId;
|
const serverId = registry[0]?.serverId;
|
||||||
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
|
if (typeof serverId !== "string" || serverId.length === 0)
|
||||||
|
return { ok: false, reason: "missing serverId" } as const;
|
||||||
prefs.serverId = serverId;
|
prefs.serverId = serverId;
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||||
return { ok: true } as const;
|
return { ok: true } as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
await assertE2EUsesSeededTestDaemon(page);
|
await assertE2EUsesSeededTestDaemon(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
if (await input.isEditable()) {
|
if (await input.isEditable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||||
if (await selectHostLabel.isVisible()) {
|
if (await selectHostLabel.isVisible()) {
|
||||||
await selectHostLabel.click();
|
await selectHostLabel.click();
|
||||||
|
|
||||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||||
const localhostOption = page.getByText('localhost', { exact: true }).first();
|
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||||
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
|
const daemonIdOption = page
|
||||||
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||||
|
.first();
|
||||||
|
|
||||||
if (await localhostOption.isVisible()) {
|
if (await localhostOption.isVisible()) {
|
||||||
await localhostOption.click();
|
await localhostOption.click();
|
||||||
@@ -365,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAgent = async (page: Page, message: string) => {
|
export const createAgent = async (page: Page, message: string) => {
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable();
|
await expect(input).toBeEditable();
|
||||||
await preferFastThinkingOption(page);
|
await preferFastThinkingOption(page);
|
||||||
await input.fill(message);
|
await input.fill(message);
|
||||||
await input.press('Enter');
|
await input.press("Enter");
|
||||||
|
|
||||||
// The composer may remain on the draft screen briefly while the initial run starts,
|
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||||
// so assert the user-visible result instead of forcing one route shape here.
|
// so assert the user-visible result instead of forcing one route shape here.
|
||||||
@@ -381,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
|
|||||||
|
|
||||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||||
const providerTrigger = page
|
const providerTrigger = page
|
||||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
||||||
if (!/codex/i.test(providerText)) {
|
if (!/codex/i.test(providerText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||||
@@ -403,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await thinkingTrigger.click();
|
await thinkingTrigger.click();
|
||||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
const menu = page.getByTestId("agent-thinking-menu").first();
|
||||||
if (!(await menu.isVisible().catch(() => false))) {
|
if (!(await menu.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||||
let selected = false;
|
let selected = false;
|
||||||
for (const label of preferredLabels) {
|
for (const label of preferredLabels) {
|
||||||
const option = menu
|
const option = menu
|
||||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||||
.first();
|
.first();
|
||||||
if (await option.isVisible().catch(() => false)) {
|
if (await option.isVisible().catch(() => false)) {
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
@@ -422,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
const options = menu.getByRole('button');
|
const options = menu.getByRole("button");
|
||||||
const count = await options.count();
|
const count = await options.count();
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const option = options.nth(index);
|
const option = options.nth(index);
|
||||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||||
if (!label) {
|
if (!label) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -440,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,15 +495,17 @@ export interface AgentConfig {
|
|||||||
export const selectProvider = async (page: Page, provider: string) => {
|
export const selectProvider = async (page: Page, provider: string) => {
|
||||||
const normalizedProvider = provider.trim();
|
const normalizedProvider = provider.trim();
|
||||||
if (!normalizedProvider) {
|
if (!normalizedProvider) {
|
||||||
throw new Error('Provider must be a non-empty string.');
|
throw new Error("Provider must be a non-empty string.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerTrigger = page
|
const providerTrigger = page
|
||||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (
|
if (
|
||||||
await providerTrigger
|
await providerTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -477,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
await providerTrigger.click();
|
await providerTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||||
await expect(providerLabel).toBeVisible();
|
await expect(providerLabel).toBeVisible();
|
||||||
await providerLabel.click();
|
await providerLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog').last();
|
const dialog = page.getByRole("dialog").last();
|
||||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
await searchInput.fill(normalizedProvider);
|
await searchInput.fill(normalizedProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click();
|
await option.click();
|
||||||
};
|
};
|
||||||
@@ -498,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
export const selectModel = async (page: Page, model: string) => {
|
export const selectModel = async (page: Page, model: string) => {
|
||||||
const normalizedModel = model.trim();
|
const normalizedModel = model.trim();
|
||||||
if (!normalizedModel) {
|
if (!normalizedModel) {
|
||||||
throw new Error('Model must be a non-empty string.');
|
throw new Error("Model must be a non-empty string.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelTrigger = page
|
const modelTrigger = page
|
||||||
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (
|
if (
|
||||||
await modelTrigger
|
await modelTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -517,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||||
await modelTrigger.click();
|
await modelTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||||
await expect(modelLabel).toBeVisible();
|
await expect(modelLabel).toBeVisible();
|
||||||
await modelLabel.click();
|
await modelLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the model dropdown to open
|
// Wait for the model dropdown to open
|
||||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
const searchInput = page.getByRole("textbox", { name: /search model/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to search/filter models
|
// Type to search/filter models
|
||||||
await searchInput.fill(normalizedModel);
|
await searchInput.fill(normalizedModel);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const exactOption = dialog
|
const exactOption = dialog
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||||
if (exactVisible) {
|
if (exactVisible) {
|
||||||
@@ -539,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
} else {
|
} else {
|
||||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||||
// select the first filtered result using keyboard confirm.
|
// select the first filtered result using keyboard confirm.
|
||||||
await searchInput.press('Enter');
|
await searchInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for dropdown to close
|
// Wait for dropdown to close
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
}
|
}
|
||||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectMode = async (page: Page, mode: string) => {
|
export const selectMode = async (page: Page, mode: string) => {
|
||||||
const modeTrigger = page
|
const modeTrigger = page
|
||||||
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||||
await modeTrigger.click();
|
await modeTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||||
await expect(modeLabel).toBeVisible();
|
await expect(modeLabel).toBeVisible();
|
||||||
await modeLabel.click();
|
await modeLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the mode dropdown to open
|
// Wait for the mode dropdown to open
|
||||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to filter modes
|
// Type to filter modes
|
||||||
await searchInput.fill(mode);
|
await searchInput.fill(mode);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
|
|
||||||
@@ -601,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
|||||||
|
|
||||||
export const createAgentInRepo = async (
|
export const createAgentInRepo = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||||
) => {
|
) => {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
@@ -610,25 +649,25 @@ export const createAgentInRepo = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||||
const promptText = page.getByTestId('permission-request-question').first();
|
const promptText = page.getByTestId("permission-request-question").first();
|
||||||
await expect(promptText).toBeVisible({ timeout });
|
await expect(promptText).toBeVisible({ timeout });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
export const allowPermission = async (page: Page) => {
|
||||||
const acceptButton = page.getByTestId('permission-request-accept').first();
|
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||||
await acceptButton.click();
|
await acceptButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
export const denyPermission = async (page: Page) => {
|
||||||
const denyButton = page.getByTestId('permission-request-deny').first();
|
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||||
await denyButton.click();
|
await denyButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||||
// Wait for the stop button to disappear
|
// Wait for the stop button to disappear
|
||||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||||
|
|
||||||
// First, let's debug what's happening - wait a bit to see the state
|
// First, let's debug what's happening - wait a bit to see the state
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
@@ -645,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
|||||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||||
|
|
||||||
// Wait for the tool call result to appear
|
// Wait for the tool call result to appear
|
||||||
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
|
await expect(toolCallResult)
|
||||||
// If no specific message, just wait for the button to disappear
|
.toBeVisible({ timeout: 10000 })
|
||||||
});
|
.catch(() => {
|
||||||
|
// If no specific message, just wait for the button to disappear
|
||||||
|
});
|
||||||
|
|
||||||
// Now wait for the stop button to disappear
|
// Now wait for the stop button to disappear
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
await expect(stopButton).not.toBeVisible({ timeout });
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const TEST_HOST_LABEL = "localhost";
|
||||||
|
|
||||||
|
export const TEST_PROVIDER_PREFERENCES = {
|
||||||
|
claude: { model: "haiku" },
|
||||||
|
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function buildDirectTcpConnection(endpoint: string) {
|
||||||
|
return {
|
||||||
|
id: `direct:${endpoint}`,
|
||||||
|
type: "directTcp" as const,
|
||||||
|
endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSeededHost(input: {
|
||||||
|
serverId: string;
|
||||||
|
endpoint: string;
|
||||||
|
label?: string;
|
||||||
|
nowIso: string;
|
||||||
|
}) {
|
||||||
|
const connection = buildDirectTcpConnection(input.endpoint);
|
||||||
|
return {
|
||||||
|
serverId: input.serverId,
|
||||||
|
label: input.label ?? TEST_HOST_LABEL,
|
||||||
|
connections: [connection],
|
||||||
|
preferredConnectionId: connection.id,
|
||||||
|
createdAt: input.nowIso,
|
||||||
|
updatedAt: input.nowIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreateAgentPreferences(serverId: string) {
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
provider: "codex" as const,
|
||||||
|
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||||
|
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||||
|
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
await page.goto(route);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
|
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||||
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||||
|
if (testId) ids.push(testId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||||
|
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||||
|
const ids = await getTabTestIds(page);
|
||||||
|
return ids.filter((id) => id.includes(kind)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||||
|
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||||
|
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||||
|
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||||
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
|
return activeTab.getAttribute("data-testid");
|
||||||
|
}
|
||||||
|
// Fallback: the tab with focused styling
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||||
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-tab");
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press Cmd+T (macOS) to open a new tab. */
|
||||||
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press("Meta+t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the launcher panel to render with its primary tiles. */
|
||||||
|
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||||
|
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "New Chat" tile. */
|
||||||
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "Terminal" tile. */
|
||||||
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the "New Chat" tile on the launcher panel. */
|
||||||
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the "Terminal" tile on the launcher panel. */
|
||||||
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||||
|
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||||
|
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||||
|
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||||
|
await tile.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for any tab in the bar to display the given title text. */
|
||||||
|
export async function waitForTabWithTitle(
|
||||||
|
page: Page,
|
||||||
|
title: string | RegExp,
|
||||||
|
timeout = 30_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||||
|
.toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||||
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
|
const buttons = page.getByTestId("workspace-new-tab");
|
||||||
|
// There might be multiple panes, each with a "+" button
|
||||||
|
// But within a single pane there should only be one
|
||||||
|
const count = await buttons.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||||
|
* Returns elapsed milliseconds.
|
||||||
|
*/
|
||||||
|
export async function measureTileTransition(
|
||||||
|
page: Page,
|
||||||
|
clickAction: () => Promise<void>,
|
||||||
|
successLocator: ReturnType<Page["locator"]>,
|
||||||
|
timeout = 5_000,
|
||||||
|
): Promise<number> {
|
||||||
|
const start = Date.now();
|
||||||
|
await clickAction();
|
||||||
|
await expect(successLocator).toBeVisible({ timeout });
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||||
|
* Returns all unique snapshots observed.
|
||||||
|
*/
|
||||||
|
export async function sampleTabsDuringTransition(
|
||||||
|
page: Page,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
durationMs = 2_000,
|
||||||
|
intervalMs = 30,
|
||||||
|
): Promise<string[][]> {
|
||||||
|
const snapshots: string[][] = [];
|
||||||
|
const startSampling = async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < durationMs) {
|
||||||
|
snapshots.push(await getTabTestIds(page));
|
||||||
|
await page.waitForTimeout(intervalMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const samplingPromise = startSampling();
|
||||||
|
await action();
|
||||||
|
await samplingPromise;
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||||
|
return createTempGitRepo(prefix);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
|
export type TerminalPerfDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createTerminal(
|
||||||
|
cwd: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<{
|
||||||
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
sendTerminalInput(
|
||||||
|
terminalId: string,
|
||||||
|
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||||
|
): void;
|
||||||
|
onTerminalStreamEvent(
|
||||||
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
|
): () => void;
|
||||||
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => TerminalPerfDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||||
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
if (line) {
|
||||||
|
lines.push(line.translateToString(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalContent(
|
||||||
|
page: Page,
|
||||||
|
predicate: (text: string) => boolean,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const text = await getTerminalBufferText(page);
|
||||||
|
if (predicate(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTerminal(
|
||||||
|
page: Page,
|
||||||
|
input: { cwd: string; terminalId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
// Boot the app at the workspace route directly.
|
||||||
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
|
// so the daemon registry is already configured when the app starts.
|
||||||
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||||
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
|
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// The workspace should now query listTerminals and discover our terminal.
|
||||||
|
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||||
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!surfaceVisible) {
|
||||||
|
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||||
|
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (tabExists) {
|
||||||
|
await terminalTab.click();
|
||||||
|
} else {
|
||||||
|
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||||
|
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||||
|
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
|
await newTerminalBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal surface to be visible
|
||||||
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => {
|
||||||
|
// overlay may never appear if attachment is instant
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalSurface.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||||
|
|
||||||
|
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatencySample = {
|
||||||
|
char: string;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures keystroke echo round-trip latency.
|
||||||
|
*
|
||||||
|
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||||
|
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||||
|
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||||
|
*/
|
||||||
|
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
throw new Error("__paseoTerminal not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ((window as any).__perfKeystroke = {
|
||||||
|
promise: null as Promise<number> | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.promise = new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
reject(new Error("keystroke echo timeout (5s)"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
function onKeyDown() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
const start = performance.now();
|
||||||
|
const disposable = term.onWriteParsed(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
disposable.dispose();
|
||||||
|
resolve(performance.now() - start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(char);
|
||||||
|
|
||||||
|
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
|||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {}
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
): Promise<string[][]> {
|
): Promise<string[][]> {
|
||||||
const durationMs = options.durationMs ?? 2_500;
|
const durationMs = options.durationMs ?? 2_500;
|
||||||
const intervalMs = options.intervalMs ?? 50;
|
const intervalMs = options.intervalMs ?? 50;
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { gotoHome } from './app';
|
import { gotoHome } from "./app";
|
||||||
|
|
||||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceLabelFromPath(value: string): string {
|
export function workspaceLabelFromPath(value: string): string {
|
||||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
return parts[parts.length - 1] ?? normalized;
|
return parts[parts.length - 1] ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||||
const trimmed = inputPath.replace(/\/+$/, '');
|
const trimmed = inputPath.replace(/\/+$/, "");
|
||||||
const candidates = new Set<string>([trimmed]);
|
const candidates = new Set<string>([trimmed]);
|
||||||
if (trimmed.startsWith('/var/')) {
|
if (trimmed.startsWith("/var/")) {
|
||||||
candidates.add(`/private${trimmed}`);
|
candidates.add(`/private${trimmed}`);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith('/private/var/')) {
|
if (trimmed.startsWith("/private/var/")) {
|
||||||
candidates.add(trimmed.replace(/^\/private/, ''));
|
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
return Array.from(candidates);
|
return Array.from(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
|
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||||
);
|
);
|
||||||
return page.locator(ids.join(',')).first();
|
return page.locator(ids.join(",")).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchWorkspaceViaSidebar(input: {
|
export async function switchWorkspaceViaSidebar(input: {
|
||||||
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
|
|||||||
|
|
||||||
export async function expectWorkspaceHeader(
|
export async function expectWorkspaceHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string }
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId('workspace-header-title');
|
const titleLocator = page.getByTestId("workspace-header-title");
|
||||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
await input.fill(marker);
|
await input.fill(marker);
|
||||||
await input.press('Enter');
|
await input.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -9,29 +9,29 @@ type TempRepo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = 'paseo-e2e-',
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean }
|
options?: { withRemote?: boolean },
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
const withRemote = options?.withRemote ?? false;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
const remoteDir = path.join(repoPath, 'remote.git');
|
const remoteDir = path.join(repoPath, "remote.git");
|
||||||
await mkdir(remoteDir, { recursive: true });
|
await mkdir(remoteDir, { recursive: true });
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByText('Direct connection', { exact: true }).click();
|
|
||||||
|
|
||||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
|
||||||
|
|
||||||
await page.getByText('Connect', { exact: true }).click();
|
|
||||||
|
|
||||||
// First-time connection prompts for an optional label.
|
|
||||||
const nameModal = page.getByTestId('name-host-modal');
|
|
||||||
const showedNameModal = await nameModal
|
|
||||||
.waitFor({ state: 'visible', timeout: 5000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
if (showedNameModal) {
|
|
||||||
await nameModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
|
||||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const seededServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!seededServerId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraPort = Number(daemonPort) + 1;
|
|
||||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
|
|
||||||
const extraDaemon = {
|
|
||||||
serverId: 'srv_e2e_extra_daemon',
|
|
||||||
label: 'extra',
|
|
||||||
connections: [
|
|
||||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const seededTestDaemon = {
|
|
||||||
serverId: seededServerId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
|
||||||
await page.addInitScript(
|
|
||||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
|
||||||
if (localStorage.getItem(seedOnceKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
let parsed: any[] = [];
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
parsed = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const list = Array.isArray(parsed) ? parsed : [];
|
|
||||||
localStorage.setItem(seedOnceKey, '1');
|
|
||||||
|
|
||||||
const next = [...list];
|
|
||||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
|
||||||
if (!hasSeeded) {
|
|
||||||
next.push(seededTestDaemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
|
||||||
if (!alreadyPresent) {
|
|
||||||
next.push(daemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
|
||||||
},
|
|
||||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto('/settings');
|
|
||||||
|
|
||||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
|
||||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
|
||||||
await hostSettingsButton.click();
|
|
||||||
|
|
||||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
|
||||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
|
||||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
|
||||||
await page.getByText('Remove host', { exact: true }).last().click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
|
||||||
await page.getByTestId('remove-host-confirm').click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
|
||||||
await page.waitForFunction(
|
|
||||||
(serverId) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extraDaemon.serverId,
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
|
||||||
|
|
||||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
// The selected host should be restored after a full reload without manual selection.
|
|
||||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure this test's storage is deterministic even under parallel load.
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const testDaemon = {
|
|
||||||
serverId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
const createAgentPreferences = {
|
|
||||||
serverId: testDaemon.serverId,
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon, preferences }) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
|
||||||
);
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
|
||||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
|
||||||
await newAgentButton.click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('New agent', { exact: true }).first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const testDaemon = {
|
|
||||||
serverId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon }) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
|
||||||
{ daemon: testDaemon }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
// Host should be auto-selected (no manual selection required).
|
|
||||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await page.getByTestId("menu-button").first().focus();
|
|
||||||
|
|
||||||
await page.keyboard.press("Shift+/");
|
|
||||||
|
|
||||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
|
||||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
|
||||||
|
|
||||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(content).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(content).toContainText("Show keyboard shortcuts");
|
|
||||||
await expect(content).toContainText("Toggle left sidebar");
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
gotoWorkspace,
|
||||||
|
waitForLauncherPanel,
|
||||||
|
assertProviderTilesVisible,
|
||||||
|
assertNewChatTileVisible,
|
||||||
|
assertTerminalTileVisible,
|
||||||
|
assertSingleNewTabButton,
|
||||||
|
clickNewTabButton,
|
||||||
|
pressNewTabShortcut,
|
||||||
|
clickNewChat,
|
||||||
|
clickTerminal,
|
||||||
|
clickProviderTile,
|
||||||
|
countTabsOfKind,
|
||||||
|
getTabTestIds,
|
||||||
|
waitForTabWithTitle,
|
||||||
|
measureTileTransition,
|
||||||
|
sampleTabsDuringTransition,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
waitForTerminalContent,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (tempRepo) await tempRepo.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Launcher Tab Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher tab", () => {
|
||||||
|
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await assertNewChatTileVisible(page);
|
||||||
|
await assertTerminalTileVisible(page);
|
||||||
|
await assertProviderTilesVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||||
|
|
||||||
|
await clickNewChat(page);
|
||||||
|
|
||||||
|
// Draft composer should appear (the agent message input)
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Launcher tab should have been replaced (not added alongside)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||||
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
|
|
||||||
|
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||||
|
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||||
|
// Total tab count should stay the same (replaced, not added)
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
// Terminal surface should appear
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (in-place replacement)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
|
||||||
|
// The launcher tab is gone, a terminal tab exists
|
||||||
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
|
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
// Click the first visible provider tile under "Terminal Agents"
|
||||||
|
const providerTiles = page.locator('[role="button"]').filter({
|
||||||
|
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||||
|
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||||
|
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The provider grid follows the label. Click the first provider tile.
|
||||||
|
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||||
|
const firstProvider = providerGrid.getByRole("button").first();
|
||||||
|
if (await firstProvider.isVisible().catch(() => false)) {
|
||||||
|
await firstProvider.click();
|
||||||
|
} else {
|
||||||
|
// Fallback: look for any provider button after the section label
|
||||||
|
const allButtons = page.getByRole("button");
|
||||||
|
const count = await allButtons.count();
|
||||||
|
let clicked = false;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = allButtons.nth(i);
|
||||||
|
const text = await btn.innerText().catch(() => "");
|
||||||
|
// Skip known non-provider buttons
|
||||||
|
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
await btn.click();
|
||||||
|
clicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!clicked) {
|
||||||
|
test.skip(true, "No provider tiles available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should see an agent panel (terminal surface or agent stream)
|
||||||
|
const agentOrTerminal = page.locator(
|
||||||
|
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||||
|
);
|
||||||
|
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (replaced, not added)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await assertSingleNewTabButton(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Terminal Title Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Terminal title propagation", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to workspace and open the terminal
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Send OSC 0 (set window title) escape sequence
|
||||||
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// Wait for the tab to reflect the new title
|
||||||
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Fire many rapid title changes — only the last should stick
|
||||||
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||||
|
}
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// The tab should eventually settle on the final title
|
||||||
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// No-Flash Transition Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher transitions (no flash)", () => {
|
||||||
|
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
// Sample tabs at high frequency across the transition
|
||||||
|
const snapshots = await sampleTabsDuringTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
2_000,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||||
|
const counts = snapshots.map((s) => s.length);
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
|
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||||
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickTerminal(page),
|
||||||
|
terminal.first(),
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Terminal surface should appear within a reasonable budget.
|
||||||
|
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||||
|
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||||
|
expect(elapsed).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
composer,
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draft replacement is fully in-memory — should be fast
|
||||||
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
// is that no blank/flash frame appears (tested above).
|
||||||
|
expect(elapsed).toBeLessThan(3_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
await page.goto('/settings');
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
|
||||||
return 'welcome';
|
|
||||||
}
|
|
||||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
|
||||||
return 'settings';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
{ timeout: 15000 }
|
|
||||||
)
|
|
||||||
.not.toBe('');
|
|
||||||
|
|
||||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
|
||||||
if (isWelcome) {
|
|
||||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('+ Add connection', { exact: true }).click();
|
|
||||||
await page.getByText('Direct connection', { exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('host:6767');
|
|
||||||
await expect(input).toBeVisible();
|
|
||||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
|
||||||
|
|
||||||
await page.getByText('Connect', { exact: true }).click();
|
|
||||||
|
|
||||||
const nameModal = page.getByTestId('name-host-modal');
|
|
||||||
if (await nameModal.isVisible().catch(() => false)) {
|
|
||||||
await nameModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ port, serverId }) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
|
||||||
const entry = parsed[0];
|
|
||||||
return (
|
|
||||||
entry?.serverId === serverId &&
|
|
||||||
Array.isArray(entry?.connections) &&
|
|
||||||
entry.connections.some(
|
|
||||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ port: daemonPort, serverId },
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
function encodeBase64Url(input: string): string {
|
|
||||||
return Buffer.from(input, 'utf8')
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this test.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const offer = {
|
|
||||||
v: 2 as const,
|
|
||||||
serverId,
|
|
||||||
daemonPublicKeyB64,
|
|
||||||
relay: { endpoint: relayEndpoint },
|
|
||||||
};
|
|
||||||
|
|
||||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
|
||||||
|
|
||||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
|
||||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
|
||||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('+ Add connection', { exact: true }).click();
|
|
||||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
|
||||||
await expect(input).toBeVisible();
|
|
||||||
await input.fill(offerUrl);
|
|
||||||
|
|
||||||
await page.getByTestId('pair-link-submit').click();
|
|
||||||
|
|
||||||
const nameHostModal = page.getByTestId('name-host-modal');
|
|
||||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
|
||||||
await nameHostModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected }) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
|
||||||
const entry = parsed[0];
|
|
||||||
const relayId = `relay:${expected.relay.endpoint}`;
|
|
||||||
return (
|
|
||||||
entry?.serverId === expected.serverId &&
|
|
||||||
Array.isArray(entry?.connections) &&
|
|
||||||
entry.connections.length === 1 &&
|
|
||||||
entry.connections[0]?.id === relayId &&
|
|
||||||
entry.connections[0]?.type === 'relay' &&
|
|
||||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
|
||||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ expected: offer },
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.focus();
|
|
||||||
|
|
||||||
const result = await page.evaluate(() => {
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (!(active instanceof HTMLTextAreaElement)) {
|
|
||||||
return {
|
|
||||||
pasted: false,
|
|
||||||
elementTag: active ? active.tagName : null,
|
|
||||||
defaultPrevented: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
|
||||||
type: "image/png",
|
|
||||||
});
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(file);
|
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
|
||||||
clipboardData: dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
active.dispatchEvent(event);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pasted: true,
|
|
||||||
elementTag: active.tagName,
|
|
||||||
defaultPrevented: event.defaultPrevented,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.pasted).toBe(true);
|
|
||||||
expect(result.defaultPrevented).toBe(true);
|
|
||||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { test, expect } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgentWithConfig,
|
|
||||||
waitForPermissionPrompt,
|
|
||||||
allowPermission,
|
|
||||||
denyPermission,
|
|
||||||
waitForAgentFinishUI,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
const FILE_CONTENT = 'Hello from permission test';
|
|
||||||
|
|
||||||
function buildWriteCommand(filePath: string): string {
|
|
||||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('permission prompts', () => {
|
|
||||||
test('allow permission creates the file', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
|
||||||
const shellCommand = buildWriteCommand(filePath);
|
|
||||||
const prompt = [
|
|
||||||
`Use your shell tool to run exactly this command:`,
|
|
||||||
shellCommand,
|
|
||||||
`Do not write outside this exact path.`,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentWithConfig(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
provider: 'claude',
|
|
||||||
mode: 'Always Ask',
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
|
||||||
|
|
||||||
await allowPermission(page);
|
|
||||||
|
|
||||||
// Wait for file to be created
|
|
||||||
await expect
|
|
||||||
.poll(() => existsSync(filePath), {
|
|
||||||
message: `File ${filePath} should exist after allowing permission`,
|
|
||||||
timeout: 30000,
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// After allowing, the file should be created successfully
|
|
||||||
// The tool call count might still be 1 if the UI updates quickly
|
|
||||||
const fileContent = await readFile(filePath, 'utf-8');
|
|
||||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deny permission does not create the file', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
|
||||||
const shellCommand = buildWriteCommand(filePath);
|
|
||||||
const prompt = [
|
|
||||||
`Use your shell tool to run exactly this command:`,
|
|
||||||
shellCommand,
|
|
||||||
`Do not write outside this exact path.`,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentWithConfig(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
provider: 'claude',
|
|
||||||
mode: 'Always Ask',
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
|
||||||
|
|
||||||
await denyPermission(page);
|
|
||||||
await waitForAgentFinishUI(page, 30000);
|
|
||||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
|
||||||
|
|
||||||
expect(existsSync(filePath)).toBe(false);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
|
||||||
|
|
||||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
|
||||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
|
||||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, nonExistentDir);
|
|
||||||
|
|
||||||
// Enter prompt text
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.fill(promptText);
|
|
||||||
|
|
||||||
// Try to submit - this should fail with an error about the directory not existing
|
|
||||||
await input.press('Enter');
|
|
||||||
|
|
||||||
// Verify error message is displayed (error includes the path)
|
|
||||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the prompt text is still preserved in the input
|
|
||||||
await expect(input).toHaveValue(promptText);
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const host = {
|
|
||||||
serverId,
|
|
||||||
label: 'relay-daemon',
|
|
||||||
connections: [
|
|
||||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
|
||||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
|
||||||
],
|
|
||||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this test.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate((daemon) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
}, host);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Should eventually connect through the relay connection.
|
|
||||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const host = {
|
|
||||||
serverId,
|
|
||||||
label: 'relay-daemon',
|
|
||||||
connections: [
|
|
||||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
|
||||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
|
||||||
],
|
|
||||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use relay by making the direct endpoint intentionally fail.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate((daemon) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
}, host);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
|
||||||
const page2 = await page.context().newPage();
|
|
||||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
|
||||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
||||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
|
||||||
});
|
|
||||||
await page2.goto('/');
|
|
||||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
|
||||||
await settingsButton2.click();
|
|
||||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Stability window: keep both tabs open and ensure they remain online.
|
|
||||||
await page.waitForTimeout(30_000);
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
|
||||||
const repoA = await createTempGitRepo();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repoA.path);
|
|
||||||
await createAgent(page, 'Agent A: respond with exactly A');
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
|
||||||
|
|
||||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
|
||||||
// preserving working directory context from the selected agent.
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
|
||||||
const searchWorkingDir = await page.evaluate(() => {
|
|
||||||
try {
|
|
||||||
return new URL(window.location.href).searchParams.get('workingDir');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
|
||||||
if (repoA.path.startsWith('/var/')) {
|
|
||||||
normalizedCandidates.add(`/private${repoA.path}`);
|
|
||||||
}
|
|
||||||
if (repoA.path.startsWith('/private/var/')) {
|
|
||||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
|
||||||
}
|
|
||||||
expect(searchWorkingDir).not.toBeNull();
|
|
||||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await repoA.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
const trigger = page.getByText("Project", { exact: true }).first();
|
|
||||||
await expect(trigger).toBeVisible();
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
|
||||||
targetFound: boolean;
|
|
||||||
visibleAtOrigin: boolean;
|
|
||||||
records: Array<{ left: number; top: number; opacity: number }>;
|
|
||||||
}>((resolve) => {
|
|
||||||
let target: HTMLElement | null = null;
|
|
||||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
|
||||||
|
|
||||||
const capture = () => {
|
|
||||||
if (!target) return;
|
|
||||||
const style = getComputedStyle(target);
|
|
||||||
records.push({
|
|
||||||
left: Number.parseFloat(style.left || "0"),
|
|
||||||
top: Number.parseFloat(style.top || "0"),
|
|
||||||
opacity: Number.parseFloat(style.opacity || "1"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryResolveTarget = (root: HTMLElement) => {
|
|
||||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
|
||||||
for (const element of stack) {
|
|
||||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
|
||||||
target = element;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
observer.disconnect();
|
|
||||||
if (!target) {
|
|
||||||
resolve({
|
|
||||||
targetFound: false,
|
|
||||||
visibleAtOrigin: false,
|
|
||||||
records: [],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAtOrigin = records.some(
|
|
||||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
targetFound: true,
|
|
||||||
visibleAtOrigin,
|
|
||||||
records,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleFrames = () => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
finish();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
for (const added of Array.from(mutation.addedNodes)) {
|
|
||||||
if (!(added instanceof HTMLElement)) continue;
|
|
||||||
if (tryResolveTarget(added)) {
|
|
||||||
sampleFrames();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
mutation.type === "attributes" &&
|
|
||||||
mutation.target instanceof HTMLElement &&
|
|
||||||
!target &&
|
|
||||||
tryResolveTarget(mutation.target)
|
|
||||||
) {
|
|
||||||
sampleFrames();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["style"],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => finish(), 2500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await trigger.click();
|
|
||||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
|
||||||
|
|
||||||
expect(probe.targetFound).toBe(true);
|
|
||||||
expect(probe.visibleAtOrigin).toBe(false);
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
|
|
||||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
|
||||||
await expect(menuButton).toBeVisible();
|
|
||||||
|
|
||||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
|
||||||
await menuButton.focus();
|
|
||||||
|
|
||||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
await expect(tooltip).toContainText('Toggle sidebar');
|
|
||||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
|
|
||||||
// Tooltip should also appear on hover.
|
|
||||||
await menuButton.blur();
|
|
||||||
await expect(tooltip).toHaveCount(0);
|
|
||||||
await menuButton.hover();
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
|
|
||||||
const triggerBox = await menuButton.boundingBox();
|
|
||||||
const tooltipBox = await tooltip.boundingBox();
|
|
||||||
expect(triggerBox).not.toBeNull();
|
|
||||||
expect(tooltipBox).not.toBeNull();
|
|
||||||
if (!triggerBox || !tooltipBox) return;
|
|
||||||
|
|
||||||
// side=right => tooltip starts to the right of the trigger.
|
|
||||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
|
||||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
|
||||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
|
||||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
|
||||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
|
||||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
|
||||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
|
||||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
|
||||||
|
|
||||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
|
||||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
|
||||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
|
||||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
|
measureKeystrokeLatency,
|
||||||
|
computePercentile,
|
||||||
|
round2,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
type LatencySample,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const LINE_COUNT = 50_000;
|
||||||
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
|
||||||
|
test.describe("Terminal wire performance", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
if (tempRepo) {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - startMs;
|
||||||
|
|
||||||
|
// seq 1 N outputs each number on its own line
|
||||||
|
const estimatedBytes = Array.from(
|
||||||
|
{ length: LINE_COUNT },
|
||||||
|
(_, i) => String(i + 1).length + 1,
|
||||||
|
).reduce((a, b) => a + b, 0);
|
||||||
|
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
lineCount: LINE_COUNT,
|
||||||
|
estimatedBytes,
|
||||||
|
elapsedMs,
|
||||||
|
throughputMBps: round2(throughputMBps),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("throughput-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||||
|
THROUGHPUT_BUDGET_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Ensure clean prompt state
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const samples: LatencySample[] = [];
|
||||||
|
const chars = "abcdefghijklmnopqrst";
|
||||||
|
|
||||||
|
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
|
||||||
|
const char = chars[i % chars.length];
|
||||||
|
const latencyMs = await measureKeystrokeLatency(page, char);
|
||||||
|
samples.push({ char, latencyMs });
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up typed characters
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
|
||||||
|
const latencies = samples.map((s) => s.latencyMs);
|
||||||
|
const p50 = computePercentile(latencies, 50);
|
||||||
|
const p95 = computePercentile(latencies, 95);
|
||||||
|
const max = Math.max(...latencies);
|
||||||
|
const min = Math.min(...latencies);
|
||||||
|
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
sampleCount: KEYSTROKE_SAMPLE_COUNT,
|
||||||
|
p50Ms: round2(p50),
|
||||||
|
p95Ms: round2(p95),
|
||||||
|
maxMs: round2(max),
|
||||||
|
minMs: round2(min),
|
||||||
|
avgMs: round2(avg),
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
char: s.char,
|
||||||
|
latencyMs: round2(s.latencyMs),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("latency-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
p95,
|
||||||
|
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||||
|
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
openNewAgentComposer,
|
|
||||||
seedWorkspaceActivity,
|
|
||||||
switchWorkspaceViaSidebar,
|
|
||||||
} from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
function percentile(values: number[], p: number): number {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const rank = Math.ceil((p / 100) * sorted.length);
|
|
||||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
||||||
return sorted[index] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(values: number[]) {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return {
|
|
||||||
count: 0,
|
|
||||||
minMs: 0,
|
|
||||||
maxMs: 0,
|
|
||||||
avgMs: 0,
|
|
||||||
p95Ms: 0,
|
|
||||||
p99Ms: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
|
||||||
return {
|
|
||||||
count: values.length,
|
|
||||||
minMs: Math.min(...values),
|
|
||||||
maxMs: Math.max(...values),
|
|
||||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
|
||||||
p95Ms: percentile(values, 95),
|
|
||||||
p99Ms: percentile(values, 99),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStressCommand(doneMarker: string): string {
|
|
||||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
|
||||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
|
||||||
return [
|
|
||||||
"i=1",
|
|
||||||
"printf '\\033[?1049h\\033[2J'",
|
|
||||||
"while [ $i -le 240 ]; do",
|
|
||||||
"printf '\\033[H'",
|
|
||||||
"r=1",
|
|
||||||
"while [ $r -le 24 ]; do",
|
|
||||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
|
||||||
"r=$((r+1))",
|
|
||||||
"done",
|
|
||||||
"sleep 0.01",
|
|
||||||
"i=$((i+1))",
|
|
||||||
"done",
|
|
||||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
|
||||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
|
||||||
].join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const text = await readFile(filePath, "utf8");
|
|
||||||
return text.includes(marker);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
|
||||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
|
||||||
const expected = currentlyExpanded ? "false" : "true";
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
|
||||||
return Date.now() - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
|
||||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
|
||||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await newTerminalButton.click();
|
|
||||||
|
|
||||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
|
||||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
|
||||||
await surface.click({ force: true });
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const monitor = {
|
|
||||||
samples: [] as number[],
|
|
||||||
active: true,
|
|
||||||
rafId: 0,
|
|
||||||
lastTs: performance.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = (ts: number) => {
|
|
||||||
if (!monitor.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
monitor.samples.push(ts - monitor.lastTs);
|
|
||||||
monitor.lastTs = ts;
|
|
||||||
monitor.rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
monitor.rafId = requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
|
||||||
{
|
|
||||||
stop: () => {
|
|
||||||
if (!monitor.active) {
|
|
||||||
return { samples: monitor.samples };
|
|
||||||
}
|
|
||||||
monitor.active = false;
|
|
||||||
cancelAnimationFrame(monitor.rafId);
|
|
||||||
return { samples: monitor.samples };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
|
||||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
|
||||||
const stressCommand = buildStressCommand(doneMarker);
|
|
||||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
const interactionLatenciesMs: number[] = [];
|
|
||||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
|
||||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
|
||||||
interactionLatenciesMs.push(latency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rafResult = await page.evaluate(() => {
|
|
||||||
const handle = (
|
|
||||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
|
||||||
).__PASEO_E2E_RAF_MONITOR__;
|
|
||||||
if (!handle || typeof handle.stop !== "function") {
|
|
||||||
return { samples: [] as number[] };
|
|
||||||
}
|
|
||||||
return handle.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
await surface.click({ force: true });
|
|
||||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
|
||||||
timeout: 120_000,
|
|
||||||
}).toBe(true);
|
|
||||||
|
|
||||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
|
||||||
(sample) => Number.isFinite(sample) && sample > 0
|
|
||||||
);
|
|
||||||
const report = {
|
|
||||||
mode: "report-only",
|
|
||||||
profile: "single-stress",
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
workload: {
|
|
||||||
frames: 240,
|
|
||||||
rowsPerFrame: 24,
|
|
||||||
frameSleepMs: 10,
|
|
||||||
doneMarker,
|
|
||||||
postMarker,
|
|
||||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
|
||||||
},
|
|
||||||
frameGapMs: {
|
|
||||||
...summarize(frameGapsMs),
|
|
||||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
|
||||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
|
||||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
|
||||||
},
|
|
||||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
|
||||||
};
|
|
||||||
|
|
||||||
await testInfo.attach("terminal-responsiveness-report", {
|
|
||||||
body: JSON.stringify(report, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
const workingDirectorySelect = page
|
|
||||||
.locator('[data-testid="working-directory-select"]:visible')
|
|
||||||
.first();
|
|
||||||
await expect(workingDirectorySelect).toBeVisible();
|
|
||||||
await workingDirectorySelect.click({ force: true });
|
|
||||||
|
|
||||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
|
||||||
await expect(searchInput).toBeVisible();
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
|
||||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
|
||||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
|
||||||
if (!(trigger instanceof HTMLElement)) {
|
|
||||||
throw new Error("Missing working-directory-select trigger.");
|
|
||||||
}
|
|
||||||
if (!(searchInput instanceof HTMLInputElement)) {
|
|
||||||
throw new Error("Missing working directory search input.");
|
|
||||||
}
|
|
||||||
if (!(container instanceof HTMLElement)) {
|
|
||||||
throw new Error("Missing combobox desktop container.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
samples: 0,
|
|
||||||
underTriggerSamples: 0,
|
|
||||||
emptyWhileSearchingSamples: 0,
|
|
||||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
|
||||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
|
||||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
|
||||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
|
||||||
logs: [] as Array<{
|
|
||||||
reason: string;
|
|
||||||
query: string;
|
|
||||||
searchDelta: number;
|
|
||||||
containerDelta: number;
|
|
||||||
hasEmpty: boolean;
|
|
||||||
containerTop: number;
|
|
||||||
containerBottom: number;
|
|
||||||
triggerTop: number;
|
|
||||||
searchBottom: number;
|
|
||||||
}>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sample = (reason: string) => {
|
|
||||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = searchInput.value.trim();
|
|
||||||
if (!query) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const searchRect = searchInput.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
|
||||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
|
||||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
|
||||||
|
|
||||||
state.samples += 1;
|
|
||||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
|
||||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
|
||||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
|
||||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
|
||||||
if (searchDelta > 2 || containerDelta > 2) {
|
|
||||||
state.underTriggerSamples += 1;
|
|
||||||
}
|
|
||||||
if (hasEmpty) {
|
|
||||||
state.emptyWhileSearchingSamples += 1;
|
|
||||||
}
|
|
||||||
if (state.logs.length < 80) {
|
|
||||||
state.logs.push({
|
|
||||||
reason,
|
|
||||||
query,
|
|
||||||
searchDelta,
|
|
||||||
containerDelta,
|
|
||||||
hasEmpty,
|
|
||||||
containerTop: containerRect.top,
|
|
||||||
containerBottom: containerRect.bottom,
|
|
||||||
triggerTop: triggerRect.top,
|
|
||||||
searchBottom: searchRect.bottom,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
|
||||||
mutationObserver.observe(container, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
resizeObserver.observe(searchInput);
|
|
||||||
|
|
||||||
let rafId = 0;
|
|
||||||
const loop = () => {
|
|
||||||
sample("raf");
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
|
|
||||||
(window as any).__paseoComboboxObserver = {
|
|
||||||
stop: () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
mutationObserver.disconnect();
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
sample("stop");
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
|
||||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
|
||||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
|
||||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const queries = [
|
|
||||||
"/tmp/paseo-stability-a",
|
|
||||||
"/tmp/paseo-stability-ab",
|
|
||||||
"/tmp/paseo-stability-abc",
|
|
||||||
"/tmp/paseo-stability-longer-branch",
|
|
||||||
"/tmp/paseo-stability-z",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const query of queries) {
|
|
||||||
await searchInput.fill("");
|
|
||||||
await searchInput.type(query, { delay: 20 });
|
|
||||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
|
||||||
await expect(customOption).toBeVisible();
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
|
||||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
|
||||||
|
|
||||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
|
||||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
|
||||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
|
||||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
|
||||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
|
||||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
getWorkspaceTabTestIds,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from './helpers/workspace-tabs'
|
|
||||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
|
||||||
const seedPrompt = `seed workspace ${Date.now()}`
|
|
||||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
|
|
||||||
// Force the setup onto the global draft surface. In slow runs the app can
|
|
||||||
// still be on a previously opened agent/workspace view where the placement
|
|
||||||
// form is not rendered.
|
|
||||||
await page.goto(`/h/${serverId}/new-agent`)
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await seedComposer.fill(seedPrompt)
|
|
||||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
|
||||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
|
||||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
|
||||||
expect(workspaceRouteToken).toBeTruthy()
|
|
||||||
await waitForWorkspaceTabsVisible(page)
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page)
|
|
||||||
|
|
||||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
|
||||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page)
|
|
||||||
|
|
||||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
|
||||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
|
||||||
if (!draftTabTestId) {
|
|
||||||
throw new Error('Expected a draft workspace tab to be created.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
|
||||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
|
||||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
|
||||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
|
||||||
|
|
||||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
|
||||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
|
||||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
|
||||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
|
|
||||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
|
||||||
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(createPrompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
|
||||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const finalIds = await getWorkspaceTabTestIds(page)
|
|
||||||
expect(finalIds).toContain(draftTabTestId)
|
|
||||||
|
|
||||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
|
||||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
|
||||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
|
||||||
|
|
||||||
const currentUrl = page.url()
|
|
||||||
if (!workspaceRouteToken) {
|
|
||||||
throw new Error('Expected workspace route token to be present.')
|
|
||||||
}
|
|
||||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { createAgentInRepo } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
getWorkspaceTabTestIds,
|
|
||||||
sampleWorkspaceTabIds,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from "./helpers/workspace-tabs";
|
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
async function expectComposerFocused(page: Page) {
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await composer.evaluate(
|
|
||||||
(element) => document.activeElement === element
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
|
||||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
|
||||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
|
||||||
|
|
||||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
|
||||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
|
||||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
|
||||||
expect(draftTabTestId).toBeTruthy();
|
|
||||||
|
|
||||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
|
||||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
|
||||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await input.fill(createPrompt);
|
|
||||||
await input.press("Enter");
|
|
||||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshots = await samplingPromise;
|
|
||||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
|
||||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
|
||||||
|
|
||||||
const finalIds = await getWorkspaceTabTestIds(page);
|
|
||||||
expect(finalIds.length).toBe(withDraftIds.length);
|
|
||||||
expect(finalIds).toContain(draftTabTestId!);
|
|
||||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
|
||||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
|
||||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await composer.fill(secondPrompt);
|
|
||||||
await composer.press("Enter");
|
|
||||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
|
||||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
|
||||||
(id) => !beforeSecondAgentIds.includes(id)
|
|
||||||
);
|
|
||||||
if (!secondAgentTabTestId) {
|
|
||||||
throw new Error("Expected second agent tab to be created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
|
||||||
if (!firstAgentTabTestId) {
|
|
||||||
throw new Error("Expected first agent tab to exist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
|
|
||||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { createAgentInRepo } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: workspacePath,
|
|
||||||
prompt: `workspace header restore ${Date.now()}`,
|
|
||||||
});
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
|
||||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
|
||||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
|
||||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
|
||||||
const workspaceTabs = page.locator(
|
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
|
||||||
);
|
|
||||||
const initialTabCount = await workspaceTabs.count();
|
|
||||||
const targetTabCount = initialTabCount + 8;
|
|
||||||
|
|
||||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
|
||||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
|
||||||
await terminalButton.click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
|
||||||
.toBeGreaterThanOrEqual(attempt + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentBoundsBefore = await agentButton.boundingBox();
|
|
||||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
|
||||||
const viewport = page.viewportSize();
|
|
||||||
|
|
||||||
expect(agentBoundsBefore).not.toBeNull();
|
|
||||||
expect(terminalBoundsBefore).not.toBeNull();
|
|
||||||
expect(viewport).not.toBeNull();
|
|
||||||
|
|
||||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
|
||||||
await tabsScroll.evaluate((el) => {
|
|
||||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
const agentBoundsAfter = await agentButton.boundingBox();
|
|
||||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
|
||||||
|
|
||||||
expect(agentBoundsAfter).not.toBeNull();
|
|
||||||
expect(terminalBoundsAfter).not.toBeNull();
|
|
||||||
|
|
||||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
|
||||||
const workspaceTabs = page.locator(
|
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
|
||||||
|
|
||||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
|
||||||
const agentBounds = await agentButton.boundingBox();
|
|
||||||
|
|
||||||
expect(lastTabBounds).not.toBeNull();
|
|
||||||
expect(agentBounds).not.toBeNull();
|
|
||||||
|
|
||||||
if (!lastTabBounds || !agentBounds) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
|
||||||
|
|
||||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
|
||||||
if (initiallyExpanded) {
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
||||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
||||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
||||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { test } from './fixtures';
|
|
||||||
import { setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
import {
|
|
||||||
expectWorkspaceHeader,
|
|
||||||
openNewAgentComposer,
|
|
||||||
seedWorkspaceActivity,
|
|
||||||
switchWorkspaceViaSidebar,
|
|
||||||
workspaceLabelFromPath,
|
|
||||||
} from './helpers/workspace-ui';
|
|
||||||
|
|
||||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
|
||||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
|
||||||
|
|
||||||
const tokenA = `SYNC_A_${Date.now()}`;
|
|
||||||
const tokenB = `SYNC_B_${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
|
||||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repoA.path);
|
|
||||||
await seedWorkspaceActivity(page, tokenA);
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repoB.path);
|
|
||||||
await seedWorkspaceActivity(page, tokenB);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-a-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoA.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-b-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoB.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-a-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoA.path),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repoA.cleanup();
|
|
||||||
await repoB.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require("eslint/config");
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ['dist/*'],
|
ignores: ["dist/*"],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,17 +2,10 @@
|
|||||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||||
polyfillScreenOrientation();
|
polyfillScreenOrientation();
|
||||||
|
|
||||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
|
||||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
|
||||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
|
||||||
attachConsole();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||||
import "./src/styles/unistyles";
|
import "./src/styles/unistyles";
|
||||||
import "expo-router/entry";
|
import "expo-router/entry";
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
const exclusionList =
|
|
||||||
require("@expo/metro/metro-config/defaults/exclusionList").default;
|
|
||||||
const { resolve } = require("metro-resolver");
|
const { resolve } = require("metro-resolver");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -18,8 +16,10 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
const escapedAppSrcRoot = appSrcRoot
|
const escapedAppSrcRoot = appSrcRoot
|
||||||
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
|
.split(path.sep)
|
||||||
.replace(/\//g, "[\\\\/]");
|
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||||
|
.join("[\\\\/]");
|
||||||
|
const pathSeparatorPattern = "[\\\\/]";
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...(config.resolver.extraNodeModules ?? {}),
|
...(config.resolver.extraNodeModules ?? {}),
|
||||||
@@ -28,9 +28,9 @@ config.resolver.extraNodeModules = {
|
|||||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||||
};
|
};
|
||||||
config.resolver.blockList = exclusionList([
|
config.resolver.blockList = new RegExp(
|
||||||
new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`),
|
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
|
||||||
]);
|
);
|
||||||
|
|
||||||
function isLocalModuleImport(moduleName) {
|
function isLocalModuleImport(moduleName) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+17
-22
@@ -1,49 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.26",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
|
"eas-build-post-install": "npm run build:workspace-deps",
|
||||||
"android": "npm run android:development",
|
"android": "npm run android:development",
|
||||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
|
||||||
"android:release": "npm run android:production",
|
"android:release": "npm run android:production",
|
||||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios:release": "expo run:ios --configuration Release",
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
"build:web": "expo export --platform web",
|
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/server": "0.1.26",
|
"@getpaseo/expo-two-way-audio": "0.1.38",
|
||||||
|
"@getpaseo/highlight": "0.1.38",
|
||||||
|
"@getpaseo/server": "0.1.38",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/css": "^1.3.0",
|
|
||||||
"@lezer/highlight": "^1.2.3",
|
|
||||||
"@lezer/html": "^1.3.13",
|
|
||||||
"@lezer/javascript": "^1.5.4",
|
|
||||||
"@lezer/json": "^1.0.3",
|
|
||||||
"@lezer/markdown": "^1.6.2",
|
|
||||||
"@lezer/python": "^1.1.18",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
@@ -52,10 +44,13 @@
|
|||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.9.0",
|
||||||
|
"@xterm/addon-ligatures": "^0.10.0",
|
||||||
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
@@ -84,7 +79,6 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"lezer-elixir": "^1.1.2",
|
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.4",
|
"react": "19.1.4",
|
||||||
@@ -117,6 +111,7 @@
|
|||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
||||||
// This allows multiple test runs in parallel across different worktrees
|
// This allows multiple test runs in parallel across different worktrees
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
|
const baseURL =
|
||||||
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: "./e2e",
|
||||||
globalSetup: './e2e/global-setup.ts',
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -16,17 +17,17 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
reporter: [['list']],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'retain-on-failure',
|
trace: "retain-on-failure",
|
||||||
screenshot: 'only-on-failure',
|
screenshot: "only-on-failure",
|
||||||
video: 'retain-on-failure',
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'Desktop Chrome',
|
name: "Desktop Chrome",
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.E2E_BASE_URL ??
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.E2E_BASE_URL ??
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="%LANG_ISO_CODE%">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>%WEB_TITLE%</title>
|
||||||
|
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||||
|
<style id="expo-reset">
|
||||||
|
/* These styles make the body full-height */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* These styles make the root element full-height */
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
[role='button'],
|
||||||
|
[role='link'],
|
||||||
|
[role='textbox'],
|
||||||
|
[role='combobox'],
|
||||||
|
[role='tab'],
|
||||||
|
[role='switch'],
|
||||||
|
[role='checkbox'],
|
||||||
|
[role='slider'],
|
||||||
|
[role='menuitem'],
|
||||||
|
[tabindex],
|
||||||
|
[contenteditable='true'] {
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
|
|||||||
userInput === "y"
|
userInput === "y"
|
||||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error during script execution: ${error.message}`);
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
@@ -108,5 +108,5 @@ rl.question(
|
|||||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
rl.close();
|
rl.close();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+283
-204
@@ -1,6 +1,12 @@
|
|||||||
import "@/styles/unistyles";
|
import "@/styles/unistyles";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
import {
|
||||||
|
Stack,
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -9,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, ActivityIndicator, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { darkTheme } from "@/styles/theme";
|
import { darkTheme } from "@/styles/theme";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -17,12 +23,16 @@ import {
|
|||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
useHosts,
|
useHosts,
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeSession,
|
useHostRuntimeClient,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -35,6 +45,7 @@ import * as Linking from "expo-linking";
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
|
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||||
@@ -46,10 +57,11 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -57,30 +69,31 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
mapPathnameToServer,
|
||||||
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
attachConsole();
|
|
||||||
const HostRuntimeBootstrapContext = createContext(false);
|
|
||||||
|
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
export type HostRuntimeBootstrapState = {
|
||||||
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
function logLeftSidebarOpenGesture(
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
event: string,
|
phase: "starting-daemon",
|
||||||
details: Record<string, unknown>
|
error: null,
|
||||||
): void {
|
retry: () => {},
|
||||||
if (!IS_DEV) {
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -88,32 +101,52 @@ function PushNotificationRouter() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
if (getTauri()) {
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
void ensureOsNotificationPermission().then((granted) => {
|
let cancelled = false;
|
||||||
console.log(
|
|
||||||
"[OSNotifications][Tauri] Startup permission preflight result:",
|
if (getIsDesktop()) {
|
||||||
granted ? "granted" : "not-granted"
|
void ensureOsNotificationPermission();
|
||||||
);
|
|
||||||
|
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||||
|
"notification-click",
|
||||||
|
(payload: unknown) => {
|
||||||
|
const data =
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
"data" in payload &&
|
||||||
|
typeof (payload as { data?: unknown }).data === "object" &&
|
||||||
|
(payload as { data?: unknown }).data !== null
|
||||||
|
? (payload as { data: Record<string, unknown> }).data
|
||||||
|
: undefined;
|
||||||
|
router.push(buildNotificationRoute(data) as any);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||||
|
if (typeof unlisten !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
unlisten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeDesktopNotificationListener = unlisten;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = globalThis as unknown as EventTarget;
|
const target = globalThis as unknown as EventTarget;
|
||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
router.push(route as any);
|
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.addEventListener(
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
|
||||||
openFromWebClick as EventListener
|
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
target.removeEventListener(
|
cancelled = true;
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
removeDesktopNotificationListener?.();
|
||||||
openFromWebClick as EventListener
|
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +174,7 @@ function PushNotificationRouter() {
|
|||||||
router.push(buildNotificationRoute(data) as any);
|
router.push(buildNotificationRoute(data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscription =
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||||
Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
|
||||||
|
|
||||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -159,18 +191,14 @@ function PushNotificationRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
const client = useHostRuntimeClient(daemon.serverId);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider
|
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||||
key={daemon.serverId}
|
|
||||||
serverId={daemon.serverId}
|
|
||||||
client={client}
|
|
||||||
>
|
|
||||||
{null}
|
{null}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
@@ -193,41 +221,99 @@ function HostSessionManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setPhase("starting-daemon");
|
||||||
|
setError(null);
|
||||||
|
setRetryToken((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
void store
|
const init = async () => {
|
||||||
.loadFromStorage()
|
const settings = await loadSettingsFromStorage();
|
||||||
.then(() => {
|
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
||||||
|
await store.loadFromStorage();
|
||||||
|
if (isDesktopManaged) {
|
||||||
|
setPhase("starting-daemon");
|
||||||
|
setError(null);
|
||||||
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
|
if (!bootstrapResult.ok) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPhase("error");
|
||||||
|
setError(bootstrapResult.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReady(true);
|
|
||||||
void store.bootstrap();
|
setPhase("connecting");
|
||||||
})
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
.catch((error) => {
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
console.error("[HostRuntime] Failed to initialize store", error);
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setReady(true);
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
|
if (!cancelled) {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void init().catch((bootstrapError) => {
|
||||||
|
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldManageDesktop) {
|
||||||
|
setPhase("error");
|
||||||
|
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [retryToken]);
|
||||||
|
|
||||||
|
const state = useMemo<HostRuntimeBootstrapState>(
|
||||||
|
() => ({
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
}),
|
||||||
|
[error, phase, retry],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
<HostRuntimeBootstrapContext.Provider value={state}>
|
||||||
{children}
|
{children}
|
||||||
</HostRuntimeBootstrapContext.Provider>
|
</HostRuntimeBootstrapContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStoreReady(): boolean {
|
export function useStoreReady(): boolean {
|
||||||
|
return useContext(HostRuntimeBootstrapContext).phase === "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
||||||
return useContext(HostRuntimeBootstrapContext);
|
return useContext(HostRuntimeBootstrapContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +321,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||||
|
const flexStyle = { flex: 1 } as const;
|
||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -248,23 +337,14 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
const isOpen = chromeEnabled
|
|
||||||
? isMobile
|
|
||||||
? mobileView === "agent-list"
|
|
||||||
: desktopAgentListOpen
|
|
||||||
: false;
|
|
||||||
const openGestureEnabled =
|
|
||||||
chromeEnabled && isMobile && mobileView === "agent";
|
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
@@ -272,27 +352,58 @@ function AppContainer({
|
|||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
|
toggleBothSidebars,
|
||||||
|
toggleFocusMode,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
isGesturing,
|
|
||||||
} = useSidebarAnimation();
|
|
||||||
|
|
||||||
// Track initial touch position for manual activation
|
const containerStyle = useMemo(
|
||||||
|
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||||
|
[theme.colors.surface0],
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View style={rowStyle}>
|
||||||
|
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
|
<View style={flexStyle}>{children}</View>
|
||||||
|
</View>
|
||||||
|
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
|
<DownloadToast />
|
||||||
|
<UpdateBanner />
|
||||||
|
<CommandCenter />
|
||||||
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
|
<KeyboardShortcutsDialog />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileGestureWrapper({
|
||||||
|
children,
|
||||||
|
chromeEnabled,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
chromeEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
|
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||||
|
useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
|
|
||||||
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
@@ -306,46 +417,31 @@ function AppContainer({
|
|||||||
|
|
||||||
const deltaX = touch.absoluteX - touchStartX.value;
|
const deltaX = touch.absoluteX - touchStartX.value;
|
||||||
|
|
||||||
// If horizontal scroll is scrolled right, fail so ScrollView handles it
|
|
||||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate after 15px rightward movement
|
|
||||||
if (deltaX > 15) {
|
if (deltaX > 15) {
|
||||||
stateManager.activate();
|
stateManager.activate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true;
|
isGesturing.value = true;
|
||||||
runOnJS(logLeftSidebarOpenGesture)("start", {
|
|
||||||
mobileView,
|
|
||||||
openGestureEnabled,
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
// Start from closed position (-windowWidth) and move towards 0
|
|
||||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||||
translateX.value = newTranslateX;
|
translateX.value = newTranslateX;
|
||||||
backdropOpacity.value = interpolate(
|
backdropOpacity.value = interpolate(
|
||||||
newTranslateX,
|
newTranslateX,
|
||||||
[-windowWidth, 0],
|
[-windowWidth, 0],
|
||||||
[0, 1],
|
[0, 1],
|
||||||
Extrapolation.CLAMP
|
Extrapolation.CLAMP,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.onEnd((event) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
// Open if dragged more than 1/3 of sidebar or fast swipe
|
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
runOnJS(logLeftSidebarOpenGesture)("end", {
|
|
||||||
translationX: event.translationX,
|
|
||||||
velocityX: event.velocityX,
|
|
||||||
shouldOpen,
|
|
||||||
mobileView,
|
|
||||||
openGestureEnabled,
|
|
||||||
});
|
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(openAgentList)();
|
runOnJS(openAgentList)();
|
||||||
@@ -364,64 +460,51 @@ function AppContainer({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
openAgentList,
|
||||||
mobileView,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
|
||||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
|
||||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
|
||||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
|
||||||
<DownloadToast />
|
|
||||||
<CommandCenter />
|
|
||||||
<ProjectPickerModal />
|
|
||||||
<KeyboardShortcutsDialog />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||||
{content}
|
{children}
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const isLoading = settingsLoading || !storeReady;
|
const systemColorScheme = useColorScheme();
|
||||||
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (settingsLoading) return;
|
||||||
if (settings.theme === "auto") {
|
if (settings.theme === "auto") {
|
||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(settings.theme);
|
UnistylesRuntime.setTheme(settings.theme);
|
||||||
}
|
}
|
||||||
}, [isLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
if (isLoading) {
|
useEffect(() => {
|
||||||
return <LoadingView />;
|
if (settingsLoading || Platform.OS !== "web") {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||||
|
});
|
||||||
|
}, [settingsLoading, resolvedTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
|
<HostSessionManager />
|
||||||
|
<FaviconStatusSync />
|
||||||
{children}
|
{children}
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
);
|
);
|
||||||
@@ -452,7 +535,9 @@ function OfferLinkListener({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
void Linking.getInitialURL()
|
||||||
|
.then(handleUrl)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
const subscription = Linking.addEventListener("url", (event) => {
|
const subscription = Linking.addEventListener("url", (event) => {
|
||||||
handleUrl(event.url);
|
handleUrl(event.url);
|
||||||
@@ -468,10 +553,22 @@ function OfferLinkListener({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
useFaviconStatus();
|
const hosts = useHosts();
|
||||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
|
const shouldShowAppChrome = activeServerId !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeServerId || hosts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||||
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
@@ -499,106 +596,88 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingView({ message }: { message?: string } = {}) {
|
function FaviconStatusSync() {
|
||||||
|
useFaviconStatus();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootStack() {
|
||||||
|
const storeReady = useStoreReady();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Stack
|
||||||
style={{
|
screenOptions={{
|
||||||
flex: 1,
|
headerShown: false,
|
||||||
justifyContent: "center",
|
animation: "none",
|
||||||
alignItems: "center",
|
contentStyle: {
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
backgroundColor: darkTheme.colors.surface0,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
|
<Stack.Protected guard={storeReady}>
|
||||||
{message ? (
|
<Stack.Screen name="welcome" />
|
||||||
<Text
|
<Stack.Screen name="settings" />
|
||||||
style={{
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
color: darkTheme.colors.foregroundMuted,
|
<Stack.Screen
|
||||||
marginTop: 16,
|
name="h/[serverId]/agent/[agentId]"
|
||||||
fontSize: 14,
|
options={{ gestureEnabled: false }}
|
||||||
}}
|
/>
|
||||||
>
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
{message}
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
</Text>
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
) : null}
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
</View>
|
<Stack.Screen name="pair-scan" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MissingDaemonView() {
|
function NavigationActiveWorkspaceObserver() {
|
||||||
return (
|
const navigationRef = useNavigationContainerRef();
|
||||||
<View
|
|
||||||
style={{
|
useEffect(() => {
|
||||||
flex: 1,
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
justifyContent: "center",
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||||
alignItems: "center",
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
padding: 24,
|
});
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||||
}}
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
>
|
});
|
||||||
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
|
return () => {
|
||||||
<Text
|
unsubscribeState();
|
||||||
style={{
|
unsubscribeReady();
|
||||||
color: darkTheme.colors.foreground,
|
};
|
||||||
marginTop: 16,
|
}, [navigationRef]);
|
||||||
textAlign: "center",
|
|
||||||
}}
|
return null;
|
||||||
>
|
|
||||||
No host configured. Open Settings to add a server URL.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
<NavigationActiveWorkspaceObserver />
|
||||||
>
|
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<BottomSheetModalProvider>
|
<QueryProvider>
|
||||||
<QueryProvider>
|
<BottomSheetModalProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<PushNotificationRouter />
|
<PushNotificationRouter />
|
||||||
<HostSessionManager />
|
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<Stack
|
<RootStack />
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
animation: "none",
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="settings" />
|
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
|
||||||
<Stack.Screen
|
|
||||||
name="h/[serverId]/agent/[agentId]"
|
|
||||||
options={{ gestureEnabled: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
|
||||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
|
||||||
</Stack>
|
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</SidebarAnimationProvider>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
</HostRuntimeBootstrapProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</BottomSheetModalProvider>
|
||||||
</BottomSheetModalProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
buildHostRootRoute,
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +14,8 @@ export default function HostAgentReadyRoute() {
|
|||||||
const redirectedRef = useRef(false);
|
const redirectedRef = useRef(false);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentCwd = useSessionStore((state) => {
|
const agentCwd = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -38,7 +37,11 @@ export default function HostAgentReadyRoute() {
|
|||||||
if (normalizedCwd) {
|
if (normalizedCwd) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId: normalizedCwd,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentCwd, agentId, router, serverId]);
|
||||||
@@ -77,7 +80,13 @@ export default function HostAgentReadyRoute() {
|
|||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
router.replace(
|
||||||
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId: cwd,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceOpenRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
@@ -17,11 +18,11 @@ export default function HostIndexRoute() {
|
|||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||||
const sessionAgents = useSessionStore(
|
const sessionAgents = useSessionStore((state) =>
|
||||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
serverId ? state.sessions[serverId]?.agents : undefined,
|
||||||
);
|
);
|
||||||
const sessionWorkspaces = useSessionStore(
|
const sessionWorkspaces = useSessionStore((state) =>
|
||||||
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
|
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,12 +45,10 @@ export default function HostIndexRoute() {
|
|||||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||||
: [];
|
: [];
|
||||||
visibleAgents.sort(
|
visibleAgents.sort(
|
||||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces
|
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||||
? Array.from(sessionWorkspaces.values())
|
|
||||||
: [];
|
|
||||||
visibleWorkspaces.sort((left, right) => {
|
visibleWorkspaces.sort((left, right) => {
|
||||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
@@ -59,18 +58,20 @@ export default function HostIndexRoute() {
|
|||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
router.replace(
|
router.replace(
|
||||||
buildHostWorkspaceAgentRoute(
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
primaryAgent.cwd.trim(),
|
workspaceId: primaryAgent.cwd.trim(),
|
||||||
primaryAgent.id
|
target: { kind: "agent", agentId: primaryAgent.id },
|
||||||
) as any
|
}) as any,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
router.replace(
|
||||||
|
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +79,7 @@ export default function HostIndexRoute() {
|
|||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [
|
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
|
||||||
pathname,
|
|
||||||
preferencesLoading,
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
sessionAgents,
|
|
||||||
sessionWorkspaces,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
|
||||||
|
|
||||||
export default function HostNewAgentRoute() {
|
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
|
||||||
|
|
||||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { AgentsScreen } from "@/screens/agents-screen";
|
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||||
|
|
||||||
export default function HostAgentsRoute() {
|
export default function HostAgentsRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
|
||||||
return <AgentsScreen serverId={serverId} />;
|
return <SessionsScreen serverId={serverId} />;
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,89 @@
|
|||||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
import { useEffect, useRef } from "react";
|
||||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
parseHostWorkspaceRouteFromPathname,
|
buildHostWorkspaceRoute,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from '@/utils/host-routes'
|
type WorkspaceOpenIntent,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const firstValue = value[0];
|
||||||
|
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||||
|
if (openIntent.kind === "agent") {
|
||||||
|
return { kind: "agent", agentId: openIntent.agentId };
|
||||||
|
}
|
||||||
|
if (openIntent.kind === "terminal") {
|
||||||
|
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||||
|
}
|
||||||
|
if (openIntent.kind === "file") {
|
||||||
|
return { kind: "file", path: openIntent.path };
|
||||||
|
}
|
||||||
|
return { kind: "draft", draftId: openIntent.draftId };
|
||||||
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const expoPathname = usePathname()
|
const router = useRouter();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
const params = useLocalSearchParams<{
|
||||||
const serverId = activeRoute?.serverId ?? ''
|
serverId?: string | string[];
|
||||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
workspaceId?: string | string[];
|
||||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
}>();
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
const globalParams = useGlobalSearchParams<{
|
||||||
|
open?: string | string[];
|
||||||
|
}>();
|
||||||
|
const serverId = getParamValue(params.serverId);
|
||||||
|
const workspaceValue = getParamValue(params.workspaceId);
|
||||||
|
const workspaceId = workspaceValue
|
||||||
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
|
: "";
|
||||||
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
|
const route = openIntent
|
||||||
|
? prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
target: getOpenIntentTarget(openIntent),
|
||||||
|
pin: openIntent.kind === "agent",
|
||||||
|
})
|
||||||
|
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
|
|
||||||
|
router.replace(route as any);
|
||||||
|
}, [openValue, router, serverId, workspaceId]);
|
||||||
|
|
||||||
|
if (openValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceScreen
|
<WorkspaceScreen
|
||||||
key={`${serverId}:${workspaceId}`}
|
key={`${serverId}:${workspaceId}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
openIntent={openIntent}
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
export const WELCOME_ROUTE = '/welcome'
|
|
||||||
|
|
||||||
export function shouldWaitOnStartupRace(input: {
|
|
||||||
onlineServerId: string | null
|
|
||||||
hasTimedOut: boolean
|
|
||||||
isDesktopStartupRace: boolean
|
|
||||||
daemonCount: number
|
|
||||||
pathname: string
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (input.pathname === WELCOME_ROUTE) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (input.hasTimedOut) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRedirectToWelcome(input: {
|
|
||||||
onlineServerId: string | null
|
|
||||||
hasTimedOut: boolean
|
|
||||||
pathname: string
|
|
||||||
isDesktopStartupRace: boolean
|
|
||||||
daemonCount: number
|
|
||||||
}): boolean {
|
|
||||||
if (input.onlineServerId || !input.hasTimedOut) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (input.pathname !== '/' && input.pathname !== '') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
|
||||||
}
|
|
||||||
@@ -1,113 +1,65 @@
|
|||||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { usePathname, useRouter } from 'expo-router'
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useHosts } from '@/runtime/host-runtime'
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
|
||||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
|
||||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
|
||||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
|
||||||
import {
|
import {
|
||||||
shouldRedirectToWelcome,
|
useHostRuntimeBootstrapState,
|
||||||
shouldWaitOnStartupRace,
|
useStoreReady,
|
||||||
WELCOME_ROUTE,
|
} from "@/app/_layout";
|
||||||
} from './index-startup'
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
const WELCOME_ROUTE = "/welcome";
|
||||||
|
|
||||||
|
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||||
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
const STARTUP_TIMEOUT_MS = 30_000
|
|
||||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
|
||||||
const runtime = getHostRuntimeStore()
|
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => {
|
() => {
|
||||||
let firstOnlineServerId: string | null = null
|
let firstOnlineServerId: string | null = null;
|
||||||
let firstOnlineAt: string | null = null
|
let firstOnlineAt: string | null = null;
|
||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
firstOnlineAt = lastOnlineAt
|
firstOnlineAt = lastOnlineAt;
|
||||||
firstOnlineServerId = serverId
|
firstOnlineServerId = serverId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstOnlineServerId
|
return firstOnlineServerId;
|
||||||
},
|
},
|
||||||
() => {
|
() => null,
|
||||||
let firstOnlineServerId: string | null = null
|
);
|
||||||
let firstOnlineAt: string | null = null
|
|
||||||
for (const serverId of serverIds) {
|
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
|
||||||
firstOnlineAt = lastOnlineAt
|
|
||||||
firstOnlineServerId = serverId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstOnlineServerId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const daemons = useHosts()
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
const storeReady = useStoreReady();
|
||||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
const hosts = useHosts();
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setHasTimedOut(true)
|
|
||||||
}, STARTUP_TIMEOUT_MS)
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onlineServerId) {
|
if (!storeReady) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (pathname !== '/' && pathname !== '') {
|
if (pathname !== "/" && pathname !== "") {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(onlineServerId) as any)
|
|
||||||
}, [onlineServerId, pathname, router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const targetRoute = anyOnlineServerId
|
||||||
if (
|
? buildHostRootRoute(anyOnlineServerId)
|
||||||
!shouldRedirectToWelcome({
|
: WELCOME_ROUTE;
|
||||||
onlineServerId,
|
router.replace(targetRoute as any);
|
||||||
hasTimedOut,
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
pathname,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.replace(WELCOME_ROUTE as any)
|
|
||||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
|
|
||||||
|
|
||||||
if (
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
shouldWaitOnStartupRace({
|
|
||||||
onlineServerId,
|
|
||||||
hasTimedOut,
|
|
||||||
isDesktopStartupRace,
|
|
||||||
daemonCount: daemons.length,
|
|
||||||
pathname,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return <StartupSplashScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onlineServerId) {
|
|
||||||
return <WelcomeScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
|
|||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import {
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
buildHostRootRoute,
|
|
||||||
buildHostSettingsRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
|
|||||||
targetServerId?: string;
|
targetServerId?: string;
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
const sourceServerId =
|
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||||
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
|
|||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
} | null>(null);
|
||||||
const pendingNameHostname = useSessionStore(
|
const pendingNameHostname = useSessionStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!pendingNameHost) return null;
|
if (!pendingNameHost) return null;
|
||||||
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
return (
|
||||||
|
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||||
|
pendingNameHost.hostname ??
|
||||||
|
null
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[pendingNameHost]
|
[pendingNameHost],
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
|
|||||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId]
|
[router, source, sourceServerId, targetServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeToSource = useCallback(() => {
|
const closeToSource = useCallback(() => {
|
||||||
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
if (targetServerId && offer.serverId !== targetServerId) {
|
if (targetServerId && offer.serverId !== targetServerId) {
|
||||||
lastScannedRef.current = null;
|
lastScannedRef.current = null;
|
||||||
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
|
Alert.alert(
|
||||||
|
"Wrong daemon",
|
||||||
|
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
|
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
|
|||||||
<Text style={styles.permissionBody}>
|
<Text style={styles.permissionBody}>
|
||||||
Allow camera access to scan the pairing QR code from your daemon.
|
Allow camera access to scan the pairing QR code from your daemon.
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
||||||
style={styles.permissionButton}
|
|
||||||
onPress={() => void requestPermission()}
|
|
||||||
>
|
|
||||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.helperText}>
|
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||||
Point your camera at the pairing QR code.
|
|
||||||
</Text>
|
|
||||||
{isPairing ? (
|
{isPairing ? (
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
Pairing…
|
Pairing…
|
||||||
|
|||||||
@@ -1,57 +1,23 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|
||||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function LegacySettingsRoute() {
|
export default function LegacySettingsRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
|
||||||
|
|
||||||
const targetServerId = useMemo(() => {
|
const targetServerId = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (preferences.serverId) {
|
|
||||||
const match = daemons.find(
|
|
||||||
(daemon) => daemon.serverId === preferences.serverId
|
|
||||||
);
|
|
||||||
if (match) {
|
|
||||||
return match.serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0]?.serverId ?? null;
|
return daemons[0]?.serverId ?? null;
|
||||||
}, [daemons, preferences.serverId]);
|
}, [daemons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferencesLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||||
}, [preferencesLoading, router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (preferencesLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return <DraftAgentScreen />;
|
return <DraftAgentScreen />;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
|
|
||||||
export default function WelcomeRoute() {
|
export default function WelcomeRoute() {
|
||||||
return <WelcomeScreen />
|
return <WelcomeScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
|
|||||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||||
baseDirectoryName: string;
|
baseDirectoryName: string;
|
||||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||||
releasePreviewUrl?: (input: {
|
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||||
attachment: AttachmentMetadata;
|
|
||||||
url: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}): AttachmentStore {
|
}): AttachmentStore {
|
||||||
const baseDirectory = FileSystem.cacheDirectory
|
const baseDirectory = FileSystem.cacheDirectory
|
||||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||||
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
|
|||||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||||
idempotent: true,
|
idempotent: true,
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encodeAttachmentsForSend(
|
export async function encodeAttachmentsForSend(
|
||||||
attachments: readonly AttachmentMetadata[] | undefined
|
attachments: readonly AttachmentMetadata[] | undefined,
|
||||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
|
|||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const valid = encoded.filter(
|
const valid = encoded.filter(
|
||||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
(entry): entry is { data: string; mimeType: string } => entry !== null,
|
||||||
);
|
);
|
||||||
return valid.length > 0 ? valid : undefined;
|
return valid.length > 0 ? valid : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAttachmentPreviewUrl(
|
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
|
||||||
attachment: AttachmentMetadata
|
|
||||||
): Promise<string> {
|
|
||||||
const store = await getAttachmentStore();
|
const store = await getAttachmentStore();
|
||||||
return await store.resolvePreviewUrl({ attachment });
|
return await store.resolvePreviewUrl({ attachment });
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAttachments(
|
export async function deleteAttachments(
|
||||||
attachments: readonly AttachmentMetadata[] | undefined
|
attachments: readonly AttachmentMetadata[] | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -113,7 +111,7 @@ export async function deleteAttachments(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user