mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
471
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 | ||
|
|
8d1def4615 | ||
|
|
7ad4482096 | ||
|
|
7ef47e9193 | ||
|
|
bdb300b919 | ||
|
|
0d0a8a791a | ||
|
|
4806cd9a51 | ||
|
|
61201d68a6 | ||
|
|
6dfcb4daa9 | ||
|
|
95e112c185 | ||
|
|
6f28027687 | ||
|
|
8122d501b0 | ||
|
|
8023e1f0c3 | ||
|
|
a2d652d8d8 | ||
|
|
bf76915884 | ||
|
|
f0c59ec534 | ||
|
|
8aa1833e2b | ||
|
|
b3d5401295 | ||
|
|
8008ea69dd | ||
|
|
42aaa4e418 | ||
|
|
0f7fa55a0f | ||
|
|
964bf3e13f | ||
|
|
e6ff15a1e0 | ||
|
|
80a7fd4f3e | ||
|
|
4547d597ef | ||
|
|
ec445e606c | ||
|
|
0095f61121 | ||
|
|
609d11ee8d | ||
|
|
a161a12a42 | ||
|
|
39b56af47b | ||
|
|
64b9b61223 | ||
|
|
7c18170d6a | ||
|
|
ca20945b93 | ||
|
|
cd4db5d81f | ||
|
|
d1fc271ad4 | ||
|
|
266eb375ff | ||
|
|
a9dcef80ed | ||
|
|
ae03e8283a | ||
|
|
5aa98f13f2 | ||
|
|
698a18e88f | ||
|
|
44cc7a3771 | ||
|
|
acb294b9eb | ||
|
|
cde10485d4 | ||
|
|
40fc81bbbc | ||
|
|
0d340959d7 | ||
|
|
b9269df3e4 | ||
|
|
dbc525c2ad | ||
|
|
56512d852b | ||
|
|
40e27f5a00 | ||
|
|
f35b0b8b7d | ||
|
|
fb5e2608e0 | ||
|
|
6d4cc53a0a | ||
|
|
66bb8c6f04 | ||
|
|
fa552b0faa | ||
|
|
51e96fa303 | ||
|
|
9fd31bfa5a | ||
|
|
fdc8d3c8db | ||
|
|
6897a400e5 | ||
|
|
cff41d78ab | ||
|
|
4de2766114 | ||
|
|
30ffda6cae | ||
|
|
7a1e12f0c0 | ||
|
|
70985088f6 | ||
|
|
a525b0e22e | ||
|
|
497159ada8 | ||
|
|
e22fb51975 | ||
|
|
a315021c75 | ||
|
|
ba1ecedca3 | ||
|
|
01af138939 | ||
|
|
faf85e038c | ||
|
|
d3cd46a810 | ||
|
|
05734e8b1b | ||
|
|
c2f3bb73a6 | ||
|
|
1ea5e5a769 | ||
|
|
49e67363b2 | ||
|
|
b9a8ba054d | ||
|
|
2f77674c55 | ||
|
|
120c1b46a4 | ||
|
|
033c4bcbf2 | ||
|
|
0f005172ce | ||
|
|
0e7dfcaf2a | ||
|
|
ebfad945e5 | ||
|
|
b5a0ee9954 | ||
|
|
8a9d738438 | ||
|
|
1d41a50e1f | ||
|
|
32fe4b3beb | ||
|
|
a692c616cb | ||
|
|
edd5503fe8 | ||
|
|
34bd8dfd1b | ||
|
|
201eb6a671 | ||
|
|
9f09a19a09 | ||
|
|
edfd2564a3 | ||
|
|
c1ebb8c915 | ||
|
|
29af07e383 | ||
|
|
17bd036359 | ||
|
|
2683dae5f9 | ||
|
|
914a5dc6ae | ||
|
|
30842398e7 | ||
|
|
b27c1b0729 | ||
|
|
fcf2c14485 | ||
|
|
57eafda6ef | ||
|
|
4925b94743 | ||
|
|
f0b09d90fd | ||
|
|
bf91ea2cc9 | ||
|
|
f83a4d08da | ||
|
|
83f540f10a | ||
|
|
602b548745 | ||
|
|
d98d5457ed | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
3397e6c589 | ||
|
|
c4cccf5bd2 | ||
|
|
0130a637c8 | ||
|
|
8c2ea33da8 | ||
|
|
0d88ce2270 | ||
|
|
6907f6e71d | ||
|
|
84638ecd9c | ||
|
|
809e5fbbdc | ||
|
|
a264058a30 | ||
|
|
702e2e7db9 | ||
|
|
c32bc847bf | ||
|
|
7a2d976081 | ||
|
|
2d9ca6983a | ||
|
|
e44e495481 | ||
|
|
bedf792616 | ||
|
|
5db9942125 | ||
|
|
a889dbcc28 | ||
|
|
5c1e869bc5 | ||
|
|
a693ff560c | ||
|
|
89baf7ff38 | ||
|
|
c65a851205 | ||
|
|
85acdbb05e | ||
|
|
4e32f9b7bd | ||
|
|
744ca7a2bc | ||
|
|
3110bae209 | ||
|
|
16efdb2c95 | ||
|
|
1e9b7f1157 | ||
|
|
018ebd5f29 | ||
|
|
4706d9e3bd | ||
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
c3313ff82a | ||
|
|
1182092b94 | ||
|
|
8d1fb45f73 | ||
|
|
8f7de021f4 | ||
|
|
65573499af | ||
|
|
9154f8fc4d | ||
|
|
3d3e327378 | ||
|
|
42cfed514c | ||
|
|
99114ddd11 | ||
|
|
e73c332e4f | ||
|
|
1594e41602 | ||
|
|
4896cfe970 | ||
|
|
dcffe46f90 | ||
|
|
7b4b42068c | ||
|
|
100502ae1e | ||
|
|
178b4cd618 | ||
|
|
9a6a8ce497 | ||
|
|
325ab500b3 | ||
|
|
60bbfdde25 | ||
|
|
71ce95de90 | ||
|
|
255c2665cc | ||
|
|
176942bbf1 | ||
|
|
1ae2f9280e | ||
|
|
49402b854f | ||
|
|
9476b3fc1d | ||
|
|
29037abd54 | ||
|
|
23ffec5072 | ||
|
|
1e64dd5509 | ||
|
|
cacbbf4053 | ||
|
|
4ca04fc661 | ||
|
|
f924d33076 | ||
|
|
763cfa9333 | ||
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 | ||
|
|
9bd5f852e7 | ||
|
|
48516f0b9c | ||
|
|
0bf8e8b5b2 | ||
|
|
994ee488b9 | ||
|
|
a854096c35 | ||
|
|
5d89f9444a | ||
|
|
63905950cc | ||
|
|
ffd07ec17c | ||
|
|
2d63bc3893 | ||
|
|
55acb8a539 | ||
|
|
4c52f272fd | ||
|
|
a91f79053c | ||
|
|
7b4db04a81 | ||
|
|
ac9c2c5642 | ||
|
|
99200eabba | ||
|
|
5f2bb87a17 | ||
|
|
897c18dd5f | ||
|
|
51a865cd24 | ||
|
|
9dc3d116b4 | ||
|
|
a4326ec5c0 | ||
|
|
cc09b61b19 | ||
|
|
26d69e2006 | ||
|
|
4b7c623592 | ||
|
|
b91884bc54 | ||
|
|
963c79265a | ||
|
|
ade1e338ea | ||
|
|
c13972c835 | ||
|
|
d8d04c545e | ||
|
|
613450bac8 | ||
|
|
cafff08a30 | ||
|
|
cb60f2a596 | ||
|
|
58d72bea87 | ||
|
|
953f7898e7 | ||
|
|
fd06e109be | ||
|
|
ba1bb1646e | ||
|
|
3ee11efcd0 | ||
|
|
1930a8a2f2 | ||
|
|
f7fd41a5f8 | ||
|
|
3af5b0f031 | ||
|
|
cce8dee21c | ||
|
|
2d02db6ae0 | ||
|
|
66732a2f48 | ||
|
|
30ebd4b777 | ||
|
|
e95554d782 | ||
|
|
5cf8b7549d | ||
|
|
9c4dee5364 | ||
|
|
c635eabff3 | ||
|
|
51411182fe | ||
|
|
f53c770a71 | ||
|
|
8d55764313 | ||
|
|
4b12ebd5c0 | ||
|
|
27c8cfbd4b | ||
|
|
07b077f1a2 | ||
|
|
ee50d3b8d0 | ||
|
|
4b93f990d2 | ||
|
|
87f297e755 | ||
|
|
b50b065bcc | ||
|
|
4e8e2589bf | ||
|
|
8eddb2ee18 | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 | ||
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
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 |
@@ -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']
|
||||||
@@ -34,15 +34,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release tag
|
- name: Resolve release tag
|
||||||
shell: bash
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Ensure GitHub release exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
release_tag="v${BASH_REMATCH[2]}"
|
exit 0
|
||||||
else
|
fi
|
||||||
release_tag="$source_tag"
|
|
||||||
|
release_args=(
|
||||||
|
release create "$RELEASE_TAG"
|
||||||
|
--repo "${{ github.repository }}"
|
||||||
|
--title "Paseo $RELEASE_TAG"
|
||||||
|
--generate-notes
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
release_args+=(--prerelease)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh "${release_args[@]}"; then
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -107,24 +125,6 @@ jobs:
|
|||||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Wait for GitHub release tag
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 90); do
|
|
||||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
|
||||||
echo "Found release for tag $RELEASE_TAG"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Upload APK to GitHub Release
|
- name: Upload APK to GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx biome format .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Typecheck all packages
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
server-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Fetch origin/main (worktree tests)
|
||||||
|
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run server tests
|
||||||
|
run: npm run test --workspace=@getpaseo/server
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
server-tests-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run Windows-critical server tests
|
||||||
|
working-directory: packages/server
|
||||||
|
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
||||||
|
|
||||||
|
app-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Run app unit tests
|
||||||
|
run: npm run test --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Build server dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/server
|
||||||
|
|
||||||
|
- name: Install agent CLIs for provider tests
|
||||||
|
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
- name: Run Playwright E2E tests
|
||||||
|
run: npm run test:e2e --workspace=@getpaseo/app
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
- name: Upload test artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-results
|
||||||
|
path: |
|
||||||
|
packages/app/test-results/
|
||||||
|
packages/app/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
relay-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build relay
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run relay tests
|
||||||
|
run: npm run test --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
cli-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install agent CLIs for provider tests
|
||||||
|
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Run CLI tests
|
||||||
|
run: npm run test --workspace=@getpaseo/cli
|
||||||
|
env:
|
||||||
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
||||||
|
PASEO_DICTATION_ENABLED: '0'
|
||||||
|
PASEO_VOICE_MODE_ENABLED: '0'
|
||||||
@@ -4,7 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-rc.*'
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
|
- '!app-v*-rc.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -26,6 +28,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,10 +9,13 @@ 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:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -35,8 +35,43 @@ env:
|
|||||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
create-release:
|
||||||
|
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: scripts
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release metadata
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||||
|
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||||
|
else
|
||||||
|
prerelease_flag=""
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
prerelease_flag="--prerelease"
|
||||||
|
fi
|
||||||
|
gh release create "$RELEASE_TAG" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Paseo $RELEASE_TAG" \
|
||||||
|
--generate-notes \
|
||||||
|
$prerelease_flag
|
||||||
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -58,24 +93,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
|
||||||
release_tag="v${BASH_REMATCH[3]}"
|
|
||||||
else
|
|
||||||
release_tag="$source_tag"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -110,24 +128,6 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
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" == "true" ]]; then
|
|
||||||
release_type="draft"
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -149,8 +149,113 @@ jobs:
|
|||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
|
|
||||||
|
- name: Upload manifest artifact
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
finalize-mac-manifest:
|
||||||
|
needs: [publish-macos]
|
||||||
|
if: ${{ needs.publish-macos.result == 'success' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: scripts
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Download manifest artifacts
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: mac-manifest-*
|
||||||
|
|
||||||
|
- name: Merge manifests
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
// Simple YAML parser for electron-builder's latest-mac.yml format
|
||||||
|
function parseManifest(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const result = { files: [] };
|
||||||
|
let currentFile = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
||||||
|
else if (line.trim().startsWith('- url:')) {
|
||||||
|
currentFile = { url: line.trim().replace('- url: ', '') };
|
||||||
|
result.files.push(currentFile);
|
||||||
|
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
||||||
|
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
||||||
|
} else if (line.trim().startsWith('size:') && currentFile) {
|
||||||
|
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
||||||
|
currentFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYaml(manifest) {
|
||||||
|
let out = `version: ${manifest.version}\n`;
|
||||||
|
out += `files:\n`;
|
||||||
|
for (const f of manifest.files) {
|
||||||
|
out += ` - url: ${f.url}\n`;
|
||||||
|
out += ` sha512: ${f.sha512}\n`;
|
||||||
|
out += ` size: ${f.size}\n`;
|
||||||
|
}
|
||||||
|
out += `path: ${manifest.path}\n`;
|
||||||
|
out += `sha512: ${manifest.sha512}\n`;
|
||||||
|
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||||
|
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||||
|
|
||||||
|
const arm64 = parseManifest(arm64Text);
|
||||||
|
const x64 = parseManifest(x64Text);
|
||||||
|
|
||||||
|
// Merge: all files from both, default path points to arm64 zip
|
||||||
|
const merged = {
|
||||||
|
version: arm64.version,
|
||||||
|
files: [...arm64.files, ...x64.files],
|
||||||
|
path: arm64.path,
|
||||||
|
sha512: arm64.sha512,
|
||||||
|
releaseDate: arm64.releaseDate || x64.releaseDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = toYaml(merged);
|
||||||
|
fs.writeFileSync('latest-mac.yml', output);
|
||||||
|
console.log('Merged manifest:\n' + output);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Upload merged manifest to release
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
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'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((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')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -164,24 +269,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
|
||||||
release_tag="v${BASH_REMATCH[3]}"
|
|
||||||
else
|
|
||||||
release_tag="$source_tag"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -216,24 +304,6 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
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" == "true" ]]; then
|
|
||||||
release_type="draft"
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -251,7 +321,8 @@ jobs:
|
|||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
|
|
||||||
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'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((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')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -265,24 +336,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
|
||||||
release_tag="v${BASH_REMATCH[3]}"
|
|
||||||
else
|
|
||||||
release_tag="$source_tag"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -325,24 +379,6 @@ jobs:
|
|||||||
npx expo export --platform web
|
npx expo export --platform web
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
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" == "true" ]]; then
|
|
||||||
release_type="draft"
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
release_type="release"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,11 +19,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
draft:
|
|
||||||
description: "Create missing release as draft."
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
@@ -48,7 +43,6 @@ jobs:
|
|||||||
REF: ${{ github.ref }}
|
REF: ${{ github.ref }}
|
||||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -70,8 +64,4 @@ jobs:
|
|||||||
args+=(--create-if-missing)
|
args+=(--create-if-missing)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
|
||||||
args+=(--draft)
|
|
||||||
fi
|
|
||||||
|
|
||||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+321
@@ -1,5 +1,326 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.54 - 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
||||||
|
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
||||||
|
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
||||||
|
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
||||||
|
|
||||||
|
## 0.1.53 - 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
||||||
|
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
||||||
|
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
||||||
|
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
|
||||||
|
- Middle-click to close tabs on desktop.
|
||||||
|
- Keyboard shortcut to cycle themes.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
||||||
|
- Streaming markdown on mobile renders significantly faster.
|
||||||
|
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
||||||
|
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
|
||||||
|
- Relay and pairing URLs are stripped from daemon logs.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Closed agent tabs no longer reappear after reconnecting.
|
||||||
|
- Desktop notification badge counts match across all workspaces.
|
||||||
|
- Host switcher status syncs correctly when switching between hosts.
|
||||||
|
|
||||||
|
## 0.1.52 - 2026-04-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
||||||
|
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
||||||
|
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
||||||
|
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
||||||
|
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
||||||
|
- Initializing agents no longer show a loading spinner as if they're running.
|
||||||
|
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
||||||
|
- Error screens on desktop are now scrollable.
|
||||||
|
- Model list refreshes in the background when you open the model selector.
|
||||||
|
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
||||||
|
|
||||||
|
## 0.1.51 - 2026-04-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
||||||
|
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
||||||
|
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Model selector no longer crashes on iPad.
|
||||||
|
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
||||||
|
- OpenCode agents show the correct terminal state and refresh models reliably.
|
||||||
|
- Follow-up messages to agents that just finished a turn now work correctly.
|
||||||
|
- Commands now load properly for Pi agents.
|
||||||
|
- Internal debug output no longer appears in Claude agent timelines.
|
||||||
|
- QR scan screen cleaned up with simpler visuals.
|
||||||
|
|
||||||
|
## 0.1.50 - 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||||
|
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||||
|
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||||
|
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||||
|
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||||
|
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||||
|
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||||
|
- Closing a workspace tab is now instant.
|
||||||
|
- Settings shows a Refresh button for providers and displays error details inline.
|
||||||
|
- Reload agent moved away from the close button to prevent accidental taps.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||||
|
- Garbled overlapping text on plan cards.
|
||||||
|
- Changes pane could show stale diffs when working with git worktrees.
|
||||||
|
- Restarting an agent quickly could crash the session.
|
||||||
|
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||||
|
- Connection and pairing dialogs now display correctly on tablets.
|
||||||
|
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||||
|
- Diff stats no longer reset to zero when reconnecting.
|
||||||
|
|
||||||
|
## 0.1.49 - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||||
|
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||||
|
- Model lists stay consistent regardless of which screen you open first.
|
||||||
|
|
||||||
|
## 0.1.48 - 2026-04-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
||||||
|
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
||||||
|
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
||||||
|
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
||||||
|
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
||||||
|
- Removed noisy agent lifecycle toasts that fired on every state change.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
||||||
|
- Input field on running agent screens now correctly receives keyboard focus.
|
||||||
|
- Mobile model selector alignment and sizing.
|
||||||
|
|
||||||
|
## 0.1.47 - 2026-04-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
||||||
|
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
||||||
|
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
||||||
|
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
|
||||||
|
|
||||||
|
## 0.1.46 - 2026-04-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
||||||
|
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
||||||
|
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
||||||
|
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
|
||||||
|
|
||||||
|
## 0.1.45 - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||||
|
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||||
|
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||||
|
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
||||||
|
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
||||||
|
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
||||||
|
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
||||||
|
- Daemon status dialog in desktop settings for quick health checks.
|
||||||
|
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
||||||
|
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||||
|
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||||
|
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||||
|
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||||
|
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||||
|
- Pinned archived agents are no longer pruned when closing tabs.
|
||||||
|
- OpenCode event stream starvation during slash command execution.
|
||||||
|
- Duplicate workspaces when multiple git worktrees share the same root.
|
||||||
|
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
||||||
|
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
||||||
|
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
||||||
|
- Input event listener race condition in the web scrollbar hook.
|
||||||
|
- Open-project screen content now vertically centered.
|
||||||
|
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
||||||
|
|
||||||
|
## 0.1.44 - 2026-04-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||||
|
- Disabled claude-acp and copilot providers from the agent registry.
|
||||||
|
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||||
|
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
||||||
|
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
||||||
|
|
||||||
|
## 0.1.43 - 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||||
|
- Searchable model favorites — quickly find and pin preferred models.
|
||||||
|
- Slash command support for OpenCode agents.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Refined model selector UX with better mobile sheet behavior.
|
||||||
|
- Workspace status now uses amber alert styling for "needs input" state.
|
||||||
|
- Themed scrollbar on message input for consistent styling.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||||
|
- Shell arguments with spaces are now properly quoted on Windows.
|
||||||
|
- Claude models with 1M context support are now correctly reported.
|
||||||
|
|
||||||
|
## 0.1.42 - 2026-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
||||||
|
|
||||||
|
## 0.1.41 - 2026-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||||
|
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||||
|
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||||
|
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
||||||
|
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
||||||
|
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
||||||
|
- Fixed context menu for copy/paste across the desktop app.
|
||||||
|
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
||||||
|
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||||
|
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||||
|
|
||||||
|
## 0.1.40 - 2026-04-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Workspace tabs can now be closed in batches.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||||
|
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||||
|
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||||
|
- File diff tracking in workspaces now works correctly on Linux.
|
||||||
|
- iPad layout now renders correctly in desktop mode.
|
||||||
|
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
||||||
|
|
||||||
|
## 0.1.39 - 2026-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
||||||
|
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
||||||
|
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
||||||
|
|
||||||
|
## 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
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# CI Test Status
|
||||||
|
|
||||||
|
Tracking progress toward all-green CI.
|
||||||
|
|
||||||
|
## CI Jobs
|
||||||
|
|
||||||
|
| Job | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| format | unknown | `npx biome format .` |
|
||||||
|
| typecheck | unknown | `npm run typecheck` |
|
||||||
|
| server-tests | unknown | unit + integration (vitest) |
|
||||||
|
| app-tests | unknown | unit tests (vitest) |
|
||||||
|
| playwright | unknown | E2E tests (playwright) |
|
||||||
|
| relay-tests | unknown | unit tests (vitest) |
|
||||||
|
| cli-tests | unknown | local tests |
|
||||||
|
|
||||||
|
## Log
|
||||||
|
|
||||||
|
- 2026-04-10: Branch created, automated agents begin iterating
|
||||||
@@ -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 format # Auto-format with Biome
|
||||||
|
npm run format:check # Check formatting without writing
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
@@ -45,10 +47,55 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **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.**
|
||||||
|
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||||
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
|
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
|
- Never change a field from optional to required.
|
||||||
|
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||||
|
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||||
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
|
||||||
## Orchestrator mode
|
## Platform gating
|
||||||
|
|
||||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
|
||||||
- Launch agents in the most permissive mode
|
|
||||||
- Set cwd to the repository root
|
### The four gates
|
||||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
|
||||||
|
| Gate | Type | When to use |
|
||||||
|
|---|---|---|
|
||||||
|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
||||||
|
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
||||||
|
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
||||||
|
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
||||||
|
|
||||||
|
### Decision matrix
|
||||||
|
|
||||||
|
| I need to... | Use |
|
||||||
|
|---|---|
|
||||||
|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
||||||
|
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
||||||
|
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
||||||
|
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
||||||
|
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
||||||
|
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Default is cross-platform.** Don't gate unless you have a specific reason.
|
||||||
|
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
use-audio-recorder.web.ts ← uses Web Audio API
|
||||||
|
use-audio-recorder.native.ts ← uses expo-audio
|
||||||
|
```
|
||||||
|
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||||
|
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||||
|
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||||
|
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||||
|
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
|
||||||
|
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
|
||||||
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
# Contributing to Paseo
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute.
|
||||||
|
|
||||||
|
## How this project works
|
||||||
|
|
||||||
|
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||||
|
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||||
|
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||||
|
|
||||||
|
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||||
|
2. **Keep it small.** One bug, one flow, one focused change.
|
||||||
|
3. **Open a PR** once there is alignment on scope.
|
||||||
|
|
||||||
|
If you want to propose a direction change, start a conversation.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
Please read these first:
|
||||||
|
|
||||||
|
- [README.md](README.md)
|
||||||
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||||
|
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||||
|
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
||||||
|
- [docs/TESTING.md](docs/TESTING.md)
|
||||||
|
- [CLAUDE.md](CLAUDE.md)
|
||||||
|
|
||||||
|
## What is most helpful
|
||||||
|
|
||||||
|
The most useful contributions right now are:
|
||||||
|
|
||||||
|
- bug fixes
|
||||||
|
- windows and linux specific fixes
|
||||||
|
- regression fixes
|
||||||
|
- doc improvements
|
||||||
|
- packaging / platform fixes
|
||||||
|
- focused UX improvements that fit the existing product direction
|
||||||
|
- tests that lock down important behavior
|
||||||
|
|
||||||
|
## Scope expectations
|
||||||
|
|
||||||
|
Please keep PRs narrow.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- fix one bug
|
||||||
|
- improve one flow
|
||||||
|
- add one focused panel or command
|
||||||
|
- tighten one piece of UI
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
- combine multiple product ideas in one PR
|
||||||
|
- bundle unrelated refactors with a feature
|
||||||
|
- sneak in roadmap decisions
|
||||||
|
|
||||||
|
If a contribution contains multiple ideas, split it up.
|
||||||
|
|
||||||
|
## Product fit matters
|
||||||
|
|
||||||
|
Paseo is an opinionated product.
|
||||||
|
|
||||||
|
When reviewing contributions, the bar is not just:
|
||||||
|
|
||||||
|
- is this useful?
|
||||||
|
- is this well implemented?
|
||||||
|
|
||||||
|
It is also:
|
||||||
|
|
||||||
|
- does this fit Paseo?
|
||||||
|
- does this add product surface that will be hard to maintain?
|
||||||
|
- does the value justify the maintenance surface it adds?
|
||||||
|
- does this solve a common need or over-serve an edge case?
|
||||||
|
- does this preserve the product's current direction?
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js matching `.tool-versions`
|
||||||
|
- npm workspaces
|
||||||
|
|
||||||
|
### Start local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# runs both daemon and expo app
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server
|
||||||
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
|
npm run dev:website
|
||||||
|
npm run cli -- ls -a -g
|
||||||
|
```
|
||||||
|
|
||||||
|
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||||
|
|
||||||
|
## Multi-platform testing
|
||||||
|
|
||||||
|
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||||
|
|
||||||
|
Common checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run test --workspaces --if-present
|
||||||
|
```
|
||||||
|
|
||||||
|
Important rules:
|
||||||
|
|
||||||
|
- always run `npm run typecheck` after changes
|
||||||
|
- tests should be deterministic
|
||||||
|
- prefer real dependencies over mocks when possible
|
||||||
|
- do not make breaking WebSocket / protocol changes
|
||||||
|
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||||
|
|
||||||
|
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Coding standards
|
||||||
|
|
||||||
|
Paseo has explicit standards. Follow them.
|
||||||
|
|
||||||
|
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
||||||
|
|
||||||
|
## PR checklist
|
||||||
|
|
||||||
|
Before opening a PR, make sure:
|
||||||
|
|
||||||
|
- there was prior discussion and alignment on scope (issue or conversation)
|
||||||
|
- the change is focused, one idea per PR
|
||||||
|
- the PR description explains what changed and why
|
||||||
|
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||||
|
- UI changes have been tested on mobile and web at minimum
|
||||||
|
- typecheck passes
|
||||||
|
- tests pass, or you clearly explain what could not be run
|
||||||
|
- relevant docs were updated if needed
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
If you are unsure whether something fits, ask first.
|
||||||
|
|
||||||
|
That is especially true for:
|
||||||
|
|
||||||
|
- new core UX
|
||||||
|
- naming / terminology changes
|
||||||
|
- new extension points
|
||||||
|
- new orchestration models
|
||||||
|
- anything that would be hard to remove later
|
||||||
|
|
||||||
|
Early alignment saves everyone time.
|
||||||
|
|
||||||
|
## Forks are fine
|
||||||
|
|
||||||
|
If you want to explore a different product direction, a fork is completely fine.
|
||||||
|
|
||||||
|
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||||
@@ -4,40 +4,115 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">One interface for all your coding agents.</p>
|
<p align="center">
|
||||||
|
<a href="https://github.com/getpaseo/paseo/stargazers">
|
||||||
|
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/getpaseo/paseo/releases">
|
||||||
|
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/moboudra">
|
||||||
|
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/jz8T2uahpH">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
|
|
||||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
||||||
|
|
||||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
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.
|
||||||
|
|
||||||
### Headless / server mode
|
### Prerequisites
|
||||||
|
|
||||||
To run the daemon on a remote or headless machine:
|
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 connect from the desktop app, mobile app, or CLI.
|
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:
|
||||||
@@ -57,8 +132,12 @@ 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
|
||||||
```
|
```
|
||||||
|
|||||||
+17
-5
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
|||||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
|
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||||
|
|
||||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||||
|
|
||||||
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
|||||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||||
|
|
||||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||||
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||||
- **Replay old messages** — Each session derives fresh encryption keys
|
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||||
|
|
||||||
### Trust model
|
### Trust model
|
||||||
|
|
||||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||||
|
|
||||||
|
## Local daemon trust boundary
|
||||||
|
|
||||||
|
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||||
|
|
||||||
|
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||||
|
|
||||||
|
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||||
|
|
||||||
|
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||||
|
|
||||||
|
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||||
|
|
||||||
## DNS rebinding protection
|
## DNS rebinding protection
|
||||||
|
|
||||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||||
@@ -51,4 +63,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.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Ad-hoc daemon testing
|
||||||
|
|
||||||
|
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createPaseoDaemon } from "./bootstrap.js";
|
||||||
|
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "warn" });
|
||||||
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
||||||
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
|
await mkdir(paseoHome, { recursive: true });
|
||||||
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(
|
||||||
|
{
|
||||||
|
listen: "127.0.0.1:0", // OS picks a free port
|
||||||
|
paseoHome,
|
||||||
|
corsAllowedOrigins: [],
|
||||||
|
allowedHosts: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
|
agentClients: {},
|
||||||
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
|
relayEnabled: false,
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
// Add custom config here, e.g.:
|
||||||
|
// providerOverrides: { ... },
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
await daemon.start();
|
||||||
|
const target = daemon.getListenTarget();
|
||||||
|
const port = target!.type === "tcp" ? target!.port : null;
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
appVersion: "0.1.54", // see gotcha #1
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
|
// ... do your testing ...
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await daemon.stop();
|
||||||
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
|
await rm(staticDir, { recursive: true, force: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
```bash
|
||||||
|
npx tsx packages/server/src/server/your-script.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the test helper
|
||||||
|
|
||||||
|
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
|
appVersion: "0.1.54",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
|
// ... test ...
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||||
|
```
|
||||||
|
|
||||||
|
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||||
|
|
||||||
|
## Common client methods
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Provider discovery
|
||||||
|
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
const models = await client.listProviderModels("claude");
|
||||||
|
const modes = await client.listProviderModes("claude");
|
||||||
|
|
||||||
|
// Agent lifecycle
|
||||||
|
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
||||||
|
await client.sendMessage(agent.id, "Hello");
|
||||||
|
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
### 1. appVersion gates provider visibility
|
||||||
|
|
||||||
|
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||||
|
|
||||||
|
Always pass `appVersion`:
|
||||||
|
```typescript
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
appVersion: "0.1.54",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Provider snapshots are async
|
||||||
|
|
||||||
|
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
||||||
|
if (entry && entry.status !== "loading") break;
|
||||||
|
await new Promise((r) => setTimeout(r, 2_000));
|
||||||
|
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. fetchAgents is required before most operations
|
||||||
|
|
||||||
|
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
||||||
|
|
||||||
|
### 4. listen: "127.0.0.1:0" for port allocation
|
||||||
|
|
||||||
|
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
||||||
|
|
||||||
|
### 5. Script must live inside packages/server
|
||||||
|
|
||||||
|
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
||||||
|
|
||||||
|
### 6. Cleanup on failure
|
||||||
|
|
||||||
|
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// ... test logic ...
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemon.stop().catch(() => undefined);
|
||||||
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. ACP providers spawn real processes
|
||||||
|
|
||||||
|
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
||||||
+3
-1
@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
|
|||||||
|
|
||||||
## Cloud build + submit (EAS)
|
## Cloud build + submit (EAS)
|
||||||
|
|
||||||
Tag pushes like `v0.1.0` trigger:
|
Stable tag pushes like `v0.1.0` trigger:
|
||||||
|
|
||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
|
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Data Model
|
||||||
|
|
||||||
|
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||||
|
|
||||||
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
$PASEO_HOME/
|
||||||
|
├── config.json # Daemon configuration
|
||||||
|
├── agents/
|
||||||
|
│ └── {project-dir}/
|
||||||
|
│ └── {agentId}.json # One file per agent
|
||||||
|
├── schedules/
|
||||||
|
│ └── {scheduleId}.json # One file per schedule
|
||||||
|
├── chat/
|
||||||
|
│ └── rooms.json # All rooms + messages
|
||||||
|
├── loops/
|
||||||
|
│ └── loops.json # All loop records
|
||||||
|
├── projects/
|
||||||
|
│ ├── projects.json # Project registry
|
||||||
|
│ └── workspaces.json # Workspace registry
|
||||||
|
└── push-tokens.json # Expo push notification tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Agent Record
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||||
|
|
||||||
|
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | UUID, primary key |
|
||||||
|
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||||
|
| `cwd` | `string` | Working directory the agent operates in |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||||
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
|
| `title` | `string?` | User-visible title |
|
||||||
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||||
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
|
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||||
|
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||||
|
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||||
|
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||||
|
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||||
|
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||||
|
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
### Nested: SerializableConfig
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `string?` | Configured title |
|
||||||
|
| `modeId` | `string?` | Configured mode |
|
||||||
|
| `model` | `string?` | Configured model |
|
||||||
|
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
||||||
|
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
||||||
|
| `extra` | `Record<string, any>?` | Provider-specific config |
|
||||||
|
| `systemPrompt` | `string?` | Custom system prompt |
|
||||||
|
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
||||||
|
|
||||||
|
### Nested: RuntimeInfo
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `provider` | `string` | Active provider |
|
||||||
|
| `sessionId` | `string?` | Active session ID |
|
||||||
|
| `model` | `string?` | Active model |
|
||||||
|
| `thinkingOptionId` | `string?` | Active thinking option |
|
||||||
|
| `modeId` | `string?` | Active mode |
|
||||||
|
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
||||||
|
|
||||||
|
### Nested: PersistenceHandle
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `provider` | `string` | Provider that owns the session |
|
||||||
|
| `sessionId` | `string` | Session ID for resumption |
|
||||||
|
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
||||||
|
| `metadata` | `Record<string, any>?` | Extra metadata |
|
||||||
|
|
||||||
|
### Nested: AgentFeature (discriminated union on `type`)
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `"toggle"` |
|
||||||
|
| `id` | `string` |
|
||||||
|
| `label` | `string` |
|
||||||
|
| `description` | `string?` |
|
||||||
|
| `tooltip` | `string?` |
|
||||||
|
| `icon` | `string?` |
|
||||||
|
| `value` | `boolean` |
|
||||||
|
|
||||||
|
**Select:**
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `"select"` |
|
||||||
|
| `id` | `string` |
|
||||||
|
| `label` | `string` |
|
||||||
|
| `description` | `string?` |
|
||||||
|
| `tooltip` | `string?` |
|
||||||
|
| `icon` | `string?` |
|
||||||
|
| `value` | `string?` |
|
||||||
|
| `options` | `AgentSelectOption[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Daemon Configuration
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/config.json`
|
||||||
|
|
||||||
|
Single file, validated with `PersistedConfigSchema`.
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
daemon: {
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
allowedHosts: true | string[],
|
||||||
|
mcp: { enabled: boolean },
|
||||||
|
cors: { allowedOrigins: string[] },
|
||||||
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
baseUrl: string
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
openai: { apiKey: string },
|
||||||
|
local: { modelsDir: string }
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
providers: {
|
||||||
|
[provider: string]: {
|
||||||
|
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||||
|
env: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||||
|
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
level, format,
|
||||||
|
console: { level, format },
|
||||||
|
file: { level, path, rotate: { maxSize, maxFiles } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional with sensible defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schedule
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||||
|
|
||||||
|
One file per schedule. ID is 8 hex characters.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | 8-char hex ID |
|
||||||
|
| `name` | `string?` | Human-readable name |
|
||||||
|
| `prompt` | `string` | The prompt to send |
|
||||||
|
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
||||||
|
| `target` | `ScheduleTarget` | What to run (see below) |
|
||||||
|
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
||||||
|
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
||||||
|
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
||||||
|
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
||||||
|
| `maxRuns` | `number?` | Max executions before completing |
|
||||||
|
| `runs` | `ScheduleRun[]` | Execution history |
|
||||||
|
|
||||||
|
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||||
|
|
||||||
|
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||||
|
- `{ type: "cron", expression: string }` — cron expression
|
||||||
|
|
||||||
|
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||||
|
|
||||||
|
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||||
|
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||||
|
|
||||||
|
### Nested: ScheduleRun
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Run ID |
|
||||||
|
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
|
| `endedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
||||||
|
| `agentId` | `string?` (UUID) | Agent used for this run |
|
||||||
|
| `output` | `string?` | Agent output text |
|
||||||
|
| `error` | `string?` | Error message if failed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Chat
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||||
|
|
||||||
|
Single file containing all rooms and messages.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rooms": [ ... ],
|
||||||
|
"messages": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatRoom
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `name` | `string` | Unique room name (case-insensitive) |
|
||||||
|
| `purpose` | `string?` | Room description |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||||
|
|
||||||
|
### ChatMessage
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `roomId` | `string` | FK to ChatRoom.id |
|
||||||
|
| `authorAgentId` | `string` | Agent ID of the author |
|
||||||
|
| `body` | `string` | Message text (supports `@mentions`) |
|
||||||
|
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||||
|
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Loop
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||||
|
|
||||||
|
Single file containing an array of all loop records.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | 8-char UUID prefix |
|
||||||
|
| `name` | `string?` | Human-readable name |
|
||||||
|
| `prompt` | `string` | Worker prompt |
|
||||||
|
| `cwd` | `string` | Working directory |
|
||||||
|
| `provider` | `string` | Default provider |
|
||||||
|
| `model` | `string?` | Default model |
|
||||||
|
| `workerProvider` | `string?` | Override provider for workers |
|
||||||
|
| `workerModel` | `string?` | Override model for workers |
|
||||||
|
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||||
|
| `verifierModel` | `string?` | Override model for verifiers |
|
||||||
|
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||||
|
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||||
|
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||||
|
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||||
|
| `maxIterations` | `number?` | Cap on iterations |
|
||||||
|
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
|
| `completedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `iterations` | `LoopIteration[]` | |
|
||||||
|
| `logs` | `LoopLogEntry[]` | |
|
||||||
|
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||||
|
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||||
|
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||||
|
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||||
|
|
||||||
|
### Nested: LoopIteration
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `index` | `number` | 1-based iteration index |
|
||||||
|
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||||
|
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||||
|
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||||
|
| `failureReason` | `string?` | |
|
||||||
|
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||||
|
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||||
|
|
||||||
|
### Nested: LoopLogEntry
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `seq` | `number` (monotonic) |
|
||||||
|
| `timestamp` | `string` (ISO 8601) |
|
||||||
|
| `iteration` | `number?` |
|
||||||
|
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||||
|
| `level` | `"info" \| "error"` |
|
||||||
|
| `text` | `string` |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyCheckResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `command` | `string` |
|
||||||
|
| `exitCode` | `number` |
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `stdout` | `string` |
|
||||||
|
| `stderr` | `string` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyPromptResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `reason` | `string` |
|
||||||
|
| `verifierAgentId` | `string?` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Registry
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||||
|
|
||||||
|
Array of project records.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `projectId` | `string` | Primary key |
|
||||||
|
| `rootPath` | `string` | Filesystem root of the project |
|
||||||
|
| `kind` | `"git" \| "non_git"` | |
|
||||||
|
| `displayName` | `string` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workspace Registry
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||||
|
|
||||||
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `workspaceId` | `string` | Primary key |
|
||||||
|
| `projectId` | `string` | FK to Project.projectId |
|
||||||
|
| `cwd` | `string` | Filesystem path |
|
||||||
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||||
|
| `displayName` | `string` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Push Token Store
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tokens": ["ExponentPushToken[...]", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-side stores (App)
|
||||||
|
|
||||||
|
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
||||||
|
|
||||||
|
### Draft Store
|
||||||
|
|
||||||
|
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
drafts: Record<draftKey, {
|
||||||
|
input: { text: string, images: AttachmentMetadata[] },
|
||||||
|
lifecycle: "active" | "abandoned" | "sent",
|
||||||
|
updatedAt: number, // epoch ms
|
||||||
|
version: number // optimistic concurrency
|
||||||
|
}>,
|
||||||
|
createModalDraft: DraftRecord | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attachment Store (Web)
|
||||||
|
|
||||||
|
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
||||||
|
|
||||||
|
Stores binary attachment blobs keyed by attachment ID.
|
||||||
|
|
||||||
|
### AttachmentMetadata
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Unique attachment ID |
|
||||||
|
| `mimeType` | `string` | MIME type |
|
||||||
|
| `storageType` | `string` | Storage backend identifier |
|
||||||
|
| `storageKey` | `string` | Key within the storage backend |
|
||||||
|
| `createdAt` | `number` | Epoch ms |
|
||||||
|
| `fileName` | `string?` | Original filename |
|
||||||
|
| `byteSize` | `number?` | Size in bytes |
|
||||||
@@ -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,206 @@
|
|||||||
|
# Mobile Testing
|
||||||
|
|
||||||
|
## Maestro
|
||||||
|
|
||||||
|
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||||
|
|
||||||
|
Run a flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
maestro test packages/app/maestro/my-flow.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||||
|
mkdir -p /tmp/maestro-out
|
||||||
|
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||||
|
|
||||||
|
### Element targeting
|
||||||
|
|
||||||
|
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component
|
||||||
|
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Flow
|
||||||
|
- tapOn:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional steps
|
||||||
|
|
||||||
|
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||||
|
|
||||||
|
### Don't use launchApp against a running dev app
|
||||||
|
|
||||||
|
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||||
|
|
||||||
|
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||||
|
|
||||||
|
### Swipe gestures
|
||||||
|
|
||||||
|
Use `start`/`end` with percentage coordinates for precise control:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Edge swipe from left to open sidebar
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||||
|
|
||||||
|
For async elements, use `extendedWaitUntil`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Online.*"
|
||||||
|
timeout: 90000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev client handling
|
||||||
|
|
||||||
|
Two reusable flows handle Expo dev client screens after launch:
|
||||||
|
|
||||||
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
## Self-verification loops
|
||||||
|
|
||||||
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|
||||||
|
This pattern also lets agents self-verify fixes without manual user testing.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. Run baseline Maestro flow (confirm feature works)
|
||||||
|
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||||
|
3. Re-run Maestro flow (confirm feature still works)
|
||||||
|
4. Repeat N iterations to catch intermittent failures
|
||||||
|
|
||||||
|
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||||
|
|
||||||
|
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||||
|
```
|
||||||
|
|
||||||
|
Key elements of the script pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
# Toggle system state
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
|
||||||
|
# Wait for change to propagate
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run Maestro flow and capture result
|
||||||
|
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo "PASS"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
|
### The crash
|
||||||
|
|
||||||
|
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||||
|
|
||||||
|
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BAD: Unistyles dynamic style on Animated.View
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// GOOD: static stylesheet + inline theme values
|
||||||
|
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const staticStyles = RNStyleSheet.create({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||||
|
|
||||||
|
## iOS Simulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Screenshot
|
||||||
|
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||||
|
|
||||||
|
# Dark/light mode
|
||||||
|
xcrun simctl ui booted appearance # check current
|
||||||
|
xcrun simctl ui booted appearance dark # set dark
|
||||||
|
xcrun simctl ui booted appearance light # set light
|
||||||
|
```
|
||||||
|
|
||||||
|
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Adding a New Provider to Paseo
|
||||||
|
|
||||||
|
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||||
|
|
||||||
|
## Two Integration Patterns
|
||||||
|
|
||||||
|
### ACP (Agent Client Protocol) -- recommended
|
||||||
|
|
||||||
|
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||||
|
|
||||||
|
Existing ACP providers: `claude-acp`, `copilot`.
|
||||||
|
|
||||||
|
### Direct
|
||||||
|
|
||||||
|
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||||
|
|
||||||
|
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACP Provider Checklist
|
||||||
|
|
||||||
|
### 1. Create the provider class
|
||||||
|
|
||||||
|
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
||||||
|
|
||||||
|
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
|
import { ACPAgentClient } from "./acp-agent.js";
|
||||||
|
|
||||||
|
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MY_PROVIDER_MODES: AgentMode[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
},
|
||||||
|
// Add more modes as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
type MyProviderClientOptions = {
|
||||||
|
logger: Logger;
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: MyProviderClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "my-provider", // Must match the ID used everywhere else
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||||
|
defaultModes: MY_PROVIDER_MODES,
|
||||||
|
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override isAvailable() if the provider needs specific auth/env vars
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
if (!(await super.isAvailable())) {
|
||||||
|
return false; // Binary not found
|
||||||
|
}
|
||||||
|
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
||||||
|
|
||||||
|
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: CopilotACPAgentClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "copilot",
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["copilot", "--acp"],
|
||||||
|
defaultModes: COPILOT_MODES,
|
||||||
|
capabilities: COPILOT_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
return super.isAvailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to the provider manifest
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||||
|
|
||||||
|
First, define the modes with visual metadata:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autonomous",
|
||||||
|
label: "Autonomous",
|
||||||
|
description: "Runs without prompting",
|
||||||
|
icon: "ShieldOff",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
||||||
|
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
||||||
|
|
||||||
|
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||||
|
// ... existing providers ...
|
||||||
|
{
|
||||||
|
id: "my-provider",
|
||||||
|
label: "My Provider",
|
||||||
|
description: "Short description of the provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: MY_PROVIDER_MODES,
|
||||||
|
// Optional: enable voice
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "default",
|
||||||
|
defaultModel: "some-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add the factory to the provider registry
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||||
|
|
||||||
|
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||||
|
// ... existing factories ...
|
||||||
|
"my-provider": (logger, runtimeSettings) =>
|
||||||
|
new MyProviderACPAgentClient({
|
||||||
|
logger,
|
||||||
|
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add a provider icon (app)
|
||||||
|
|
||||||
|
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface MyProviderIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
|
<Path d="..." />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
|
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||||
|
|
||||||
|
### 5. Add E2E test config
|
||||||
|
|
||||||
|
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const agentConfigs = {
|
||||||
|
// ... existing configs ...
|
||||||
|
"my-provider": {
|
||||||
|
provider: "my-provider",
|
||||||
|
model: "default-model-id",
|
||||||
|
modes: {
|
||||||
|
full: "autonomous", // Mode with no permission prompts
|
||||||
|
ask: "default", // Mode that requires permission approval
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, AgentTestConfig>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an availability check in `isProviderAvailable()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case "my-provider":
|
||||||
|
return (
|
||||||
|
isCommandAvailable("my-agent-binary") &&
|
||||||
|
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `allProviders` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const allProviders: AgentProvider[] = [
|
||||||
|
"claude",
|
||||||
|
"claude-acp",
|
||||||
|
"codex",
|
||||||
|
"copilot",
|
||||||
|
"opencode",
|
||||||
|
"my-provider",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
This is required after every change per project rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct Provider Checklist
|
||||||
|
|
||||||
|
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
||||||
|
|
||||||
|
### Interfaces to implement
|
||||||
|
|
||||||
|
**`AgentClient`** -- factory for sessions and model listing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentClient {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
// Optional:
|
||||||
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`AgentSession`** -- a running agent conversation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentSession {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly id: string | null;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||||
|
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||||
|
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||||
|
getAvailableModes(): Promise<AgentMode[]>;
|
||||||
|
getCurrentMode(): Promise<string | null>;
|
||||||
|
setMode(modeId: string): Promise<void>;
|
||||||
|
getPendingPermissions(): AgentPermissionRequest[];
|
||||||
|
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||||
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
|
interrupt(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
// Optional:
|
||||||
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||||
|
2. Add to the provider manifest (same as ACP step 2 above)
|
||||||
|
3. Add factory to the registry (same as ACP step 3 above)
|
||||||
|
4. Add icon (same as ACP step 4 above)
|
||||||
|
5. Add E2E config (same as ACP step 5 above)
|
||||||
|
6. Run typecheck
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual testing with the CLI
|
||||||
|
|
||||||
|
Start the daemon if not already running, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch an agent with your provider
|
||||||
|
paseo run --provider my-provider
|
||||||
|
|
||||||
|
# Launch with a specific model and mode
|
||||||
|
paseo run --provider my-provider --model some-model --mode default
|
||||||
|
|
||||||
|
# List running agents
|
||||||
|
paseo ls -a -g
|
||||||
|
|
||||||
|
# Check if the provider reports models
|
||||||
|
paseo models --provider my-provider
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E test patterns
|
||||||
|
|
||||||
|
The E2E configs in `agent-configs.ts` expose two helpers:
|
||||||
|
|
||||||
|
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
||||||
|
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
||||||
|
|
||||||
|
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
||||||
|
|
||||||
|
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
||||||
|
|
||||||
|
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
||||||
|
|
||||||
|
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
||||||
|
|
||||||
|
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
||||||
|
|
||||||
|
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||||
|
|
||||||
|
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||||
+115
-11
@@ -2,8 +2,21 @@
|
|||||||
|
|
||||||
All workspaces share one version and release together.
|
All workspaces share one version and release together.
|
||||||
|
|
||||||
|
## Two paths
|
||||||
|
|
||||||
|
There are two supported ways to ship from `main`:
|
||||||
|
|
||||||
|
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||||
|
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
|
Before running any stable patch release command:
|
||||||
|
|
||||||
|
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||||
|
- Make sure local `npm run typecheck` passes on that commit.
|
||||||
|
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:patch
|
npm run release:patch
|
||||||
```
|
```
|
||||||
@@ -12,34 +25,56 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
|||||||
|
|
||||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||||
|
|
||||||
|
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||||
|
|
||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck # Verify the exact commit you intend to release
|
||||||
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Draft release flow
|
## Release candidate flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
|
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||||
|
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||||
|
|
||||||
|
Use the RC path when you need to:
|
||||||
|
|
||||||
|
- test a build manually in a Linux or Windows VM
|
||||||
|
- send a build to a user who is hitting a specific problem
|
||||||
|
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||||
|
|
||||||
|
## Website behavior
|
||||||
|
|
||||||
|
- The website download page points to GitHub's latest published **stable** release.
|
||||||
|
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||||
|
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## 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 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.
|
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||||
|
|
||||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||||
|
|
||||||
|
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||||
|
|
||||||
|
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Desktop (all platforms)
|
# Desktop (all platforms)
|
||||||
@@ -52,22 +87,91 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
|||||||
|
|
||||||
# Android APK
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
|
# RC
|
||||||
|
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
|
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||||
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
|
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||||
|
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||||
|
|
||||||
## 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`
|
- `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 uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||||
|
|
||||||
|
## Changelog format
|
||||||
|
|
||||||
|
Stable release notes depend on the changelog heading format. The heading **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.
|
||||||
|
|
||||||
|
## Changelog policy
|
||||||
|
|
||||||
|
- `CHANGELOG.md` is for **final stable releases only**.
|
||||||
|
- Do not add or edit changelog entries while iterating on RCs.
|
||||||
|
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||||
|
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||||
|
|
||||||
|
## Changelog ownership
|
||||||
|
|
||||||
|
- **Only Claude should write changelog entries.**
|
||||||
|
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||||
|
|
||||||
|
## Changelog voice
|
||||||
|
|
||||||
|
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||||
|
|
||||||
|
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||||
|
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||||
|
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||||
|
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||||
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
|
## Pre-release sanity check
|
||||||
|
|
||||||
|
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|
||||||
|
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||||
|
|
||||||
|
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||||
|
>
|
||||||
|
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||||
|
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||||
|
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||||
|
>
|
||||||
|
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||||
|
|
||||||
|
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||||
|
|
||||||
|
## Changelog scope
|
||||||
|
|
||||||
|
The changelog always covers **stable-to-HEAD**:
|
||||||
|
|
||||||
|
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||||
|
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||||
|
|
||||||
|
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
|
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||||
|
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||||
|
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||||
- [ ] 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 `npm run release:promote` 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,68 @@
|
|||||||
|
# Plan Approval Normalization
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||||
|
|
||||||
|
## Compatibility Constraints
|
||||||
|
|
||||||
|
- Older clients must remain compatible with newer daemons.
|
||||||
|
- All new wire fields must be optional.
|
||||||
|
- Existing plan permissions without action metadata must still render and work.
|
||||||
|
- Existing question permissions must keep their current behavior.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Shared abstraction
|
||||||
|
|
||||||
|
Add optional permission action definitions to the shared permission request/response types.
|
||||||
|
|
||||||
|
- Permission requests may include `actions`.
|
||||||
|
- Permission responses may include `selectedActionId`.
|
||||||
|
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||||
|
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
|
||||||
|
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||||
|
|
||||||
|
- Always expose `Reject`.
|
||||||
|
- Always expose `Implement`.
|
||||||
|
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||||
|
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||||
|
|
||||||
|
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||||
|
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||||
|
- On `Reject`, resolve without starting a follow-up turn.
|
||||||
|
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||||
|
|
||||||
|
### Manager and state sync
|
||||||
|
|
||||||
|
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||||
|
|
||||||
|
- Refresh current mode
|
||||||
|
- Refresh pending permissions
|
||||||
|
- Refresh runtime info
|
||||||
|
- Refresh features
|
||||||
|
- Persist refreshed state
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||||
|
|
||||||
|
- If `actions` are absent, fall back to legacy buttons.
|
||||||
|
- Plan cards should use `Implement` as the default primary label.
|
||||||
|
- Do not add provider-specific rendering branches.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||||
|
2. App tests for generic plan-action rendering
|
||||||
|
3. Claude tests for third action when resuming from a more permissive mode
|
||||||
|
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||||
|
5. Manager tests for post-permission state refresh
|
||||||
|
6. `npm run typecheck`
|
||||||
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-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||||
|
|
||||||
|
# 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
+331
-148
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -48,6 +48,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@agentclientprotocol/sdk": {
|
||||||
|
"version": "0.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz",
|
||||||
|
"integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ai-sdk/gateway": {
|
"node_modules/@ai-sdk/gateway": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
|
||||||
@@ -3510,6 +3519,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -14831,6 +14847,13 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chroma-js": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(BSD-3-Clause AND Apache-2.0)"
|
||||||
|
},
|
||||||
"node_modules/chrome-launcher": {
|
"node_modules/chrome-launcher": {
|
||||||
"version": "0.15.2",
|
"version": "0.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
|
||||||
@@ -15418,6 +15441,24 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-fetch": {
|
"node_modules/cross-fetch": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||||
@@ -15855,6 +15896,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-rename-keys": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"kind-of": "^3.0.2",
|
||||||
|
"rename-keys": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-rename-keys/node_modules/kind-of": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-buffer": "^1.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -15864,18 +15932,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/default-shell": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/defaults": {
|
"node_modules/defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||||
@@ -16718,6 +16774,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-log": {
|
||||||
|
"version": "5.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
|
||||||
|
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-publish": {
|
"node_modules/electron-publish": {
|
||||||
"version": "26.8.1",
|
"version": "26.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
||||||
@@ -18414,6 +18479,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -18461,35 +18533,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/execa": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cross-spawn": "^7.0.3",
|
|
||||||
"get-stream": "^6.0.0",
|
|
||||||
"human-signals": "^2.1.0",
|
|
||||||
"is-stream": "^2.0.0",
|
|
||||||
"merge-stream": "^2.0.0",
|
|
||||||
"npm-run-path": "^4.0.1",
|
|
||||||
"onetime": "^5.1.2",
|
|
||||||
"signal-exit": "^3.0.3",
|
|
||||||
"strip-final-newline": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/execa/node_modules/signal-exit": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/expect": {
|
"node_modules/expect": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
|
||||||
@@ -21775,18 +21818,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-stream": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-symbol-description": {
|
"node_modules/get-symbol-description": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||||
@@ -22498,15 +22529,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/human-signals": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/humanize-ms": {
|
"node_modules/humanize-ms": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||||
@@ -23235,6 +23257,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -25117,6 +25140,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lucide-react-native": {
|
"node_modules/lucide-react-native": {
|
||||||
"version": "0.546.0",
|
"version": "0.546.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
|
||||||
@@ -25274,6 +25306,25 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/material-icon-theme": {
|
||||||
|
"version": "5.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz",
|
||||||
|
"integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chroma-js": "^3.1.2",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"svgson": "^5.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.55.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/material-extensions"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -26446,6 +26497,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -27352,18 +27404,6 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/npm-run-path": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-key": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nth-check": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
@@ -27589,6 +27629,7 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mimic-fn": "^2.1.0"
|
"mimic-fn": "^2.1.0"
|
||||||
@@ -28152,6 +28193,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pi-acp": {
|
||||||
|
"version": "0.0.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
|
||||||
|
"integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.12.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pi-acp": "dist/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pi-acp/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -30138,6 +30213,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rename-keys": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -30968,50 +31053,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-env": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"default-shell": "^2.0.0",
|
|
||||||
"execa": "^5.1.1",
|
|
||||||
"strip-ansi": "^7.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shell-env/node_modules/ansi-regex": {
|
|
||||||
"version": "6.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shell-env/node_modules/strip-ansi": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^6.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.3",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
@@ -31944,15 +31985,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-final-newline": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
|
||||||
@@ -32126,6 +32158,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svgson": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-rename-keys": "^0.2.1",
|
||||||
|
"xml-reader": "2.4.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
@@ -34532,6 +34575,16 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-lexer": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||||
@@ -34542,6 +34595,17 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-reader": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^2.0.0",
|
||||||
|
"xml-lexer": "^0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml2js": {
|
"node_modules/xml2js": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
||||||
@@ -34842,16 +34906,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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/expo-two-way-audio": "0.1.32",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -34926,17 +34990,22 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"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",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/app/node_modules/expo-clipboard": {
|
"packages/app/node_modules/expo-clipboard": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
@@ -34954,8 +35023,32 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app/node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/app/node_modules/zod": {
|
"packages/app/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -34963,11 +35056,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.32",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -34987,6 +35080,8 @@
|
|||||||
},
|
},
|
||||||
"packages/cli/node_modules/chalk": {
|
"packages/cli/node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
|
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||||
@@ -34997,6 +35092,8 @@
|
|||||||
},
|
},
|
||||||
"packages/cli/node_modules/commander": {
|
"packages/cli/node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -35004,10 +35101,12 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.32",
|
"@getpaseo/cli": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
@@ -35040,7 +35139,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35241,7 +35340,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35267,7 +35366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35283,13 +35382,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/relay": "0.1.32",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@sctg/sentencepiece-js": "^1.1.0",
|
"@sctg/sentencepiece-js": "^1.1.0",
|
||||||
@@ -35299,16 +35400,18 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.1.0",
|
"fast-uri": "^3.1.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rotating-file-stream": "^3.2.9",
|
"rotating-file-stream": "^3.2.9",
|
||||||
"shell-env": "^4.0.3",
|
|
||||||
"sherpa-onnx": "1.12.28",
|
"sherpa-onnx": "1.12.28",
|
||||||
"sherpa-onnx-node": "1.12.28",
|
"sherpa-onnx-node": "1.12.28",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
@@ -35326,14 +35429,26 @@
|
|||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"tsx": "^4.6.0",
|
"tsx": "^4.6.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/@isaacs/ttlcache": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
|
||||||
|
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.6",
|
"ajv": "^6.12.6",
|
||||||
@@ -35355,6 +35470,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -35369,6 +35486,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
@@ -35415,6 +35534,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/accepts": {
|
"packages/server/node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
@@ -35426,6 +35547,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/ajv": {
|
"packages/server/node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -35440,6 +35563,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/ansi-regex": {
|
"packages/server/node_modules/ansi-regex": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -35450,6 +35575,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/body-parser": {
|
"packages/server/node_modules/body-parser": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
@@ -35468,6 +35595,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/content-disposition": {
|
"packages/server/node_modules/content-disposition": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "5.2.1"
|
"safe-buffer": "5.2.1"
|
||||||
@@ -35478,6 +35607,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/cookie-signature": {
|
"packages/server/node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
@@ -35485,6 +35616,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/finalhandler": {
|
"packages/server/node_modules/finalhandler": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
@@ -35500,6 +35633,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/fresh": {
|
"packages/server/node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -35507,6 +35642,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/media-typer": {
|
"packages/server/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -35514,6 +35651,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/merge-descriptors": {
|
"packages/server/node_modules/merge-descriptors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -35524,6 +35663,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/mime-types": {
|
"packages/server/node_modules/mime-types": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
@@ -35534,11 +35675,28 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/negotiator": {
|
"packages/server/node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/p-limit": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yocto-queue": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/raw-body": {
|
"packages/server/node_modules/raw-body": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
@@ -35572,6 +35730,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/send": {
|
"packages/server/node_modules/send": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
@@ -35592,6 +35752,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/serve-static": {
|
"packages/server/node_modules/serve-static": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -35605,6 +35767,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/strip-ansi": {
|
"packages/server/node_modules/strip-ansi": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.0.1"
|
"ansi-regex": "^6.0.1"
|
||||||
@@ -35618,6 +35782,8 @@
|
|||||||
},
|
},
|
||||||
"packages/server/node_modules/type-is": {
|
"packages/server/node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -35628,8 +35794,22 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/yocto-queue": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/zod": {
|
"packages/server/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -35637,13 +35817,14 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
"@tanstack/react-router": "^1.120.3",
|
"@tanstack/react-router": "^1.120.3",
|
||||||
"@tanstack/react-start": "^1.120.3",
|
"@tanstack/react-start": "^1.120.3",
|
||||||
"framer-motion": "^12.35.2",
|
"framer-motion": "^12.35.2",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.1.4",
|
"react": "^19.1.4",
|
||||||
"react-dom": "^19.1.4",
|
"react-dom": "^19.1.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -35663,6 +35844,8 @@
|
|||||||
},
|
},
|
||||||
"packages/website/node_modules/@types/node": {
|
"packages/website/node_modules/@types/node": {
|
||||||
"version": "22.19.6",
|
"version": "22.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||||
|
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+29
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -41,21 +41,26 @@
|
|||||||
"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",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"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",
|
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||||
"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",
|
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||||
|
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||||
|
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||||
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && 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/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/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",
|
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||||
"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",
|
"@biomejs/biome": "^2.4.8",
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
"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",
|
||||||
@@ -76,7 +82,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",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
- "!v*-rc.*"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { test } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
archiveAgentFromDaemon,
|
||||||
|
archiveAgentFromSessions,
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
expectSessionRowVisible,
|
||||||
|
expectWorkspaceArchiveOutcome,
|
||||||
|
openSessions,
|
||||||
|
openWorkspaceWithAgents,
|
||||||
|
primeAdditionalPage,
|
||||||
|
resetSeededPageState,
|
||||||
|
reloadWorkspace,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
|
||||||
|
test.describe("Archive tab reconciliation", () => {
|
||||||
|
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("archive-tab-");
|
||||||
|
client = await connectArchiveTabDaemonClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await client?.close().catch(() => undefined);
|
||||||
|
await tempRepo?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const passivePage = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await primeAdditionalPage(passivePage);
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await resetSeededPageState(passivePage);
|
||||||
|
await openSessions(page);
|
||||||
|
await expectSessionRowVisible(page, archived.title);
|
||||||
|
await expectSessionRowVisible(page, surviving.title);
|
||||||
|
await openSessions(passivePage);
|
||||||
|
await expectSessionRowVisible(passivePage, archived.title);
|
||||||
|
await expectSessionRowVisible(passivePage, surviving.title);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||||
|
await archiveAgentFromDaemon(client, archived.id);
|
||||||
|
await expectWorkspaceArchiveOutcome(page, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await reloadWorkspace(passivePage, tempRepo.path);
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await passivePage.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const passivePage = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await primeAdditionalPage(passivePage);
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await resetSeededPageState(passivePage);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||||
|
await openSessions(page);
|
||||||
|
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||||
|
await reloadWorkspace(page, tempRepo.path);
|
||||||
|
await expectWorkspaceArchiveOutcome(page, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await passivePage.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -448,12 +448,15 @@ export default async function globalSetup() {
|
|||||||
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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +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 { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
@@ -83,33 +84,36 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (config: {
|
new (
|
||||||
url: string;
|
config: DaemonClientConfig,
|
||||||
clientId: string;
|
) => DaemonClientInstance
|
||||||
clientType: "cli";
|
|
||||||
}) => DaemonClientInstance
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
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: DaemonClientConfig) => DaemonClientInstance;
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
}) => DaemonClientInstance;
|
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `app-e2e-${randomUUID()}`,
|
clientId: `app-e2e-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -127,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
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.4-mini",
|
||||||
thinkingOptionId: "low",
|
thinkingOptionId: "low",
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||||
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostSessionsRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export type ArchiveTabAgent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArchiveTabDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createAgent(options: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
modeId: string;
|
||||||
|
cwd: string;
|
||||||
|
title: string;
|
||||||
|
initialPrompt: string;
|
||||||
|
}): Promise<{ id: string }>;
|
||||||
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||||
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonPort(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
if (daemonPort === "6767") {
|
||||||
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||||
|
}
|
||||||
|
return daemonPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeededStoragePayload() {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
daemon: buildSeededHost({
|
||||||
|
serverId: getServerId(),
|
||||||
|
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||||
|
nowIso,
|
||||||
|
}),
|
||||||
|
preferences: buildCreateAgentPreferences(getServerId()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveTabDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (
|
||||||
|
config: ArchiveTabDaemonClientConfig,
|
||||||
|
) => ArchiveTabDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIdleAgent(
|
||||||
|
client: ArchiveTabDaemonClient,
|
||||||
|
input: { cwd: string; title: string },
|
||||||
|
): Promise<ArchiveTabAgent> {
|
||||||
|
const created = await client.createAgent({
|
||||||
|
provider: "opencode",
|
||||||
|
model: "opencode/gpt-5-nano",
|
||||||
|
modeId: "default",
|
||||||
|
cwd: input.cwd,
|
||||||
|
title: input.title,
|
||||||
|
initialPrompt: "Reply with exactly READY.",
|
||||||
|
});
|
||||||
|
const finished = await client.waitForFinish(created.id, 120_000);
|
||||||
|
if (finished.status !== "idle") {
|
||||||
|
throw new Error(
|
||||||
|
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
title: input.title,
|
||||||
|
cwd: input.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAgentFromDaemon(
|
||||||
|
client: ArchiveTabDaemonClient,
|
||||||
|
agentId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.archiveAgent(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||||
|
const seedNonce = randomUUID();
|
||||||
|
const { daemon, preferences } = buildSeededStoragePayload();
|
||||||
|
|
||||||
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||||
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
|
});
|
||||||
|
await page.addInitScript(
|
||||||
|
({ daemon, preferences, seedNonce }) => {
|
||||||
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
|
if (disableValue) {
|
||||||
|
localStorage.removeItem(disableOnceKey);
|
||||||
|
if (disableValue === seedNonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
localStorage.removeItem("@paseo:settings");
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
},
|
||||||
|
{ daemon, preferences, seedNonce },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSeededPageState(page: Page): Promise<void> {
|
||||||
|
const { daemon, preferences } = buildSeededStoragePayload();
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(
|
||||||
|
({ daemon, preferences }) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
localStorage.removeItem("@paseo:settings");
|
||||||
|
},
|
||||||
|
{ daemon, preferences },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceWithAgents(
|
||||||
|
page: Page,
|
||||||
|
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
||||||
|
): Promise<void> {
|
||||||
|
const serverId = getServerId();
|
||||||
|
for (const agent of agents) {
|
||||||
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceArchiveOutcome(
|
||||||
|
page: Page,
|
||||||
|
input: { archivedAgentId: string; survivingAgentId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
||||||
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSessions(page: Page): Promise<void> {
|
||||||
|
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||||
|
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await sessionsButton.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionRowByTitle(page: Page, title: string) {
|
||||||
|
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAgentFromSessions(
|
||||||
|
page: Page,
|
||||||
|
input: { agentId: string; title: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const row = getSessionRowByTitle(page, input.title);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
const box = await row.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||||
|
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await archiveButton.click();
|
||||||
|
await expectSessionRowArchived(page, input.title);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
|
|||||||
|
|
||||||
export const TEST_PROVIDER_PREFERENCES = {
|
export const TEST_PROVIDER_PREFERENCES = {
|
||||||
claude: { model: "haiku" },
|
claude: { model: "haiku" },
|
||||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function buildDirectTcpConnection(endpoint: string) {
|
export function buildDirectTcpConnection(endpoint: string) {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
|
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
|
||||||
|
type NewWorkspaceDaemonClient = Pick<
|
||||||
|
ServerDaemonClient,
|
||||||
|
| "archivePaseoWorktree"
|
||||||
|
| "archiveWorkspace"
|
||||||
|
| "close"
|
||||||
|
| "connect"
|
||||||
|
| "createPaseoWorktree"
|
||||||
|
| "openProject"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type NewWorkspaceDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||||
|
|
||||||
|
export type OpenedProject = {
|
||||||
|
workspaceId: string;
|
||||||
|
projectKey: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
workspaceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonPort(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
if (daemonPort === "6767") {
|
||||||
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||||
|
}
|
||||||
|
return daemonPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (
|
||||||
|
config: NewWorkspaceDaemonClientConfig,
|
||||||
|
) => NewWorkspaceDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWorkspace(payload: OpenProjectPayload) {
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.workspace) {
|
||||||
|
throw new Error("openProject returned no workspace.");
|
||||||
|
}
|
||||||
|
return payload.workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||||
|
const pathname = new URL(page.url()).pathname;
|
||||||
|
const match = pathname.match(
|
||||||
|
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
||||||
|
);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceIdFromSidebarRowTestId(
|
||||||
|
testId: string,
|
||||||
|
input: { serverId: string; previousWorkspaceId: string },
|
||||||
|
): string | null {
|
||||||
|
const prefix = `sidebar-workspace-row-${input.serverId}:`;
|
||||||
|
if (!testId.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspaceId = testId.slice(prefix.length).trim();
|
||||||
|
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectViaDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<OpenedProject> {
|
||||||
|
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||||
|
return {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveWorkspaceFromDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveLocalWorkspaceFromDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = await client.archiveWorkspace(workspaceId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.archivedAt) {
|
||||||
|
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorktreeViaDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
input: { cwd: string; slug: string },
|
||||||
|
): Promise<OpenedProject> {
|
||||||
|
const payload = await client.createPaseoWorktree({
|
||||||
|
cwd: input.cwd,
|
||||||
|
worktreeSlug: input.slug,
|
||||||
|
});
|
||||||
|
const workspace = requireWorkspace(payload);
|
||||||
|
return {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickNewWorkspaceButton(
|
||||||
|
page: Page,
|
||||||
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await projectRow.hover();
|
||||||
|
|
||||||
|
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||||
|
): Promise<{ workspaceId: string }> {
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
const sidebarWorkspaceRows = page.locator(
|
||||||
|
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
||||||
|
);
|
||||||
|
workspaceId =
|
||||||
|
sidebarRowTestIds
|
||||||
|
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
||||||
|
.find((id) => id !== null) ?? null;
|
||||||
|
if (workspaceId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||||
|
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Expected a newly created workspace to load.",
|
||||||
|
`Current URL: ${page.url()}`,
|
||||||
|
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdWorkspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspaceLabelFromPath(workspaceId),
|
||||||
|
subtitle: input.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { workspaceId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
readyState: number;
|
||||||
|
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||||
|
close: (code?: number, reason?: string) => void;
|
||||||
|
binaryType?: string;
|
||||||
|
on?: (event: string, listener: (...args: any[]) => void) => void;
|
||||||
|
off?: (event: string, listener: (...args: any[]) => void) => void;
|
||||||
|
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
|
||||||
|
addEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||||
|
removeEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||||
|
onopen?: ((event: any) => void) | null;
|
||||||
|
onclose?: ((event: any) => void) | null;
|
||||||
|
onerror?: ((event: any) => void) | null;
|
||||||
|
onmessage?: ((event: any) => void) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeWebSocketFactory = (
|
||||||
|
url: string,
|
||||||
|
options?: { headers?: Record<string, string> },
|
||||||
|
) => WebSocketLike;
|
||||||
|
|
||||||
|
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
|
||||||
|
return (url: string, options?: { headers?: Record<string, string> }) =>
|
||||||
|
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalPerfDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (
|
||||||
|
config: TerminalPerfDaemonClientConfig,
|
||||||
|
) => TerminalPerfDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
|
});
|
||||||
|
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}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceUrl(cwd: string): string {
|
||||||
|
return buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
|
||||||
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
|
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||||
|
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||||
|
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
|
await page
|
||||||
|
.getByText("localhost", { exact: true })
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||||
|
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
await terminalTab.click();
|
||||||
|
|
||||||
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
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.scrollIntoViewIfNeeded();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import {
|
||||||
|
archiveWorkspaceFromDaemon,
|
||||||
|
archiveLocalWorkspaceFromDaemon,
|
||||||
|
assertNewWorkspaceSidebarAndHeader,
|
||||||
|
clickNewWorkspaceButton,
|
||||||
|
connectNewWorkspaceDaemonClient,
|
||||||
|
createWorktreeViaDaemon,
|
||||||
|
openProjectViaDaemon,
|
||||||
|
} from "./helpers/new-workspace";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
expectWorkspaceHeader,
|
||||||
|
switchWorkspaceViaSidebar,
|
||||||
|
workspaceLabelFromPath,
|
||||||
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
test.describe("New workspace flow", () => {
|
||||||
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
|
const localWorkspaceIds = new Set<string>();
|
||||||
|
const createdWorktreeIds = new Set<string>();
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
client = await connectNewWorkspaceDaemonClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (client) {
|
||||||
|
for (const workspaceId of createdWorktreeIds) {
|
||||||
|
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
for (const workspaceId of localWorkspaceIds) {
|
||||||
|
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdWorktreeIds.clear();
|
||||||
|
localWorkspaceIds.clear();
|
||||||
|
await client?.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||||
|
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
|
||||||
|
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
|
||||||
|
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||||
|
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: secondWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(secondRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondRepo.cleanup();
|
||||||
|
await firstRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: openedProject.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(tempRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await clickNewWorkspaceButton(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdWorkspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||||
|
subtitle: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
||||||
|
has: page.getByText("New Agent", { exact: true }),
|
||||||
|
});
|
||||||
|
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Maestro takeScreenshot artifacts
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Small pause for close animation
|
||||||
|
- takeScreenshot: 00-sidebar-closed
|
||||||
|
|
||||||
|
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Verify sidebar opened
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- takeScreenshot: 01-sidebar-opened
|
||||||
|
|
||||||
|
# Close sidebar via swipe left
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
- takeScreenshot: 02-sidebar-closed-again
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verification loop for sidebar theme bug.
|
||||||
|
#
|
||||||
|
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
||||||
|
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
||||||
|
# still works. Runs N iterations to catch intermittent failures.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
||||||
|
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
WAIT_SECS="${2:-1}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
echo "=== Sidebar Theme Bug Verification ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
echo ""
|
||||||
|
echo "========== Iteration $i / $ITERATIONS =========="
|
||||||
|
|
||||||
|
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
||||||
|
echo "Current appearance: $CURRENT"
|
||||||
|
|
||||||
|
if [ "$CURRENT" = "dark" ]; then
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
echo "Switched to light mode"
|
||||||
|
else
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
echo "Switched to dark mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting ${WAIT_SECS}s..."
|
||||||
|
sleep "$WAIT_SECS"
|
||||||
|
|
||||||
|
ITER_DIR="$OUT_DIR/iter-$i"
|
||||||
|
mkdir -p "$ITER_DIR"
|
||||||
|
|
||||||
|
# Run maestro from the output dir so takeScreenshot artifacts land there
|
||||||
|
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo " -> PASS (iteration $i)"
|
||||||
|
else
|
||||||
|
echo " -> FAIL (iteration $i) — bug reproduced!"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restore to dark mode
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
||||||
|
echo "Output: $OUT_DIR"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.32",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@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/expo-two-way-audio": "0.1.32",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "*",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.32",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -108,12 +108,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"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",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* Prevent white flash before React mounts */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body {
|
||||||
|
background-color: #181B1A;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* These styles make the root element full-height */
|
/* These styles make the root element full-height */
|
||||||
#root {
|
#root {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+350
-116
@@ -14,10 +14,10 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
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 { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
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 { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
@@ -25,10 +25,15 @@ import {
|
|||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
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 { useOpenProject } from "@/hooks/use-open-project";
|
||||||
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,
|
||||||
@@ -53,7 +58,7 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsDesktop } from "@/constants/layout";
|
import { getIsElectronRuntime, useIsCompactFormFactor } 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";
|
||||||
@@ -64,7 +69,9 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -74,20 +81,48 @@ import {
|
|||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
const HostRuntimeBootstrapContext = createContext(false);
|
|
||||||
|
export type HostRuntimeBootstrapState = {
|
||||||
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const firstValue = value[0];
|
||||||
|
if (typeof firstValue !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = firstValue.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
|
phase: "starting-daemon",
|
||||||
|
error: null,
|
||||||
|
retry: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (getIsDesktop()) {
|
if (getIsElectronRuntime()) {
|
||||||
void ensureOsNotificationPermission();
|
void ensureOsNotificationPermission();
|
||||||
|
|
||||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||||
@@ -204,41 +239,126 @@ 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;
|
||||||
|
let cancelAnyOnline: (() => void) | null = null;
|
||||||
|
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;
|
||||||
if (cancelled) {
|
await store.loadFromStorage();
|
||||||
return;
|
if (isDesktopManaged) {
|
||||||
}
|
setPhase("starting-daemon");
|
||||||
setReady(true);
|
setError(null);
|
||||||
void store.bootstrap();
|
|
||||||
})
|
let raceSettled = false;
|
||||||
.catch((error) => {
|
|
||||||
console.error("[HostRuntime] Failed to initialize store", error);
|
const anyOnline = store.waitForAnyConnectionOnline();
|
||||||
|
cancelAnyOnline = anyOnline.cancel;
|
||||||
|
|
||||||
|
const bootstrapPromise = (async (): Promise<
|
||||||
|
{ type: "online" } | { type: "error"; error: string }
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
|
if (!bootstrapResult.ok) {
|
||||||
|
return { type: "error", error: bootstrapResult.error };
|
||||||
|
}
|
||||||
|
if (!cancelled && !raceSettled) {
|
||||||
|
setPhase("connecting");
|
||||||
|
}
|
||||||
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
|
return { type: "online" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
||||||
|
bootstrapPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
raceSettled = true;
|
||||||
|
anyOnline.cancel();
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setReady(true);
|
if (result.type === "online") {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setPhase("error");
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
} 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;
|
||||||
|
cancelAnyOnline?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +375,8 @@ interface AppContainerProps {
|
|||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({
|
||||||
children,
|
children,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
@@ -262,23 +384,62 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
const { settings, updateSettings } = useAppSettings();
|
||||||
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 toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const cycleTheme = useCallback(() => {
|
||||||
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
|
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||||
|
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
||||||
|
}, [settings.theme, updateSettings]);
|
||||||
|
|
||||||
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
|
const isElectron = getIsElectronRuntime();
|
||||||
|
const windowW = isWeb ? window.innerWidth : undefined;
|
||||||
|
const windowH = isWeb ? window.innerHeight : undefined;
|
||||||
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||||
|
const ua = isWeb ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[layout-debug]",
|
||||||
|
JSON.stringify({
|
||||||
|
breakpoint: bp,
|
||||||
|
isCompactLayout,
|
||||||
|
isElectron,
|
||||||
|
chromeEnabled,
|
||||||
|
isFocusModeEnabled,
|
||||||
|
agentListOpen,
|
||||||
|
sidebarWidth,
|
||||||
|
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||||
|
unistylesScreen: { w: screenW, h: screenH },
|
||||||
|
window: { w: windowW, h: windowH },
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
userAgent: ua,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -289,10 +450,12 @@ function AppContainer({
|
|||||||
const content = (
|
const content = (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||||
|
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||||
|
)}
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
@@ -301,7 +464,7 @@ function AppContainer({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isMobile) {
|
if (!isCompactLayout) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,14 +481,28 @@ function MobileGestureWrapper({
|
|||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
const {
|
||||||
useSidebarAnimation();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
openAgentList();
|
||||||
|
}, [openAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -368,7 +545,7 @@ function MobileGestureWrapper({
|
|||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(openAgentList)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -383,8 +560,9 @@ function MobileGestureWrapper({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
handleGestureOpen,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
openGestureRef,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
],
|
],
|
||||||
@@ -399,24 +577,34 @@ function MobileGestureWrapper({
|
|||||||
|
|
||||||
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 { theme } = useUnistyles();
|
||||||
|
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(THEME_TO_UNISTYLES[settings.theme]);
|
||||||
}
|
}
|
||||||
}, [isLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
if (isLoading) {
|
useEffect(() => {
|
||||||
return <LoadingView />;
|
if (settingsLoading || isNative) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDesktopWindowControls({
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
foregroundColor: theme.colors.foreground,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
|
});
|
||||||
|
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
@@ -445,7 +633,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -470,6 +658,84 @@ function OfferLinkListener({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenProjectEventPayload {
|
||||||
|
path?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenProjectListener() {
|
||||||
|
const hosts = useHosts();
|
||||||
|
const serverId = hosts[0]?.serverId ?? null;
|
||||||
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
|
const openProject = useOpenProject(serverId);
|
||||||
|
const pendingPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
const maybeOpenProject = (inputPath: string) => {
|
||||||
|
const nextPath = inputPath.trim();
|
||||||
|
if (!nextPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPathRef.current = nextPath;
|
||||||
|
|
||||||
|
if (!serverId || !client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathToOpen = pendingPathRef.current;
|
||||||
|
pendingPathRef.current = null;
|
||||||
|
if (!pathToOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void openProject(pathToOpen).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pull any path that was passed on cold start (before the listener existed).
|
||||||
|
// Store in the ref even if this effect instance is disposed — the next
|
||||||
|
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||||
|
void getDesktopHost()
|
||||||
|
?.getPendingOpenProject?.()
|
||||||
|
?.then((pending) => {
|
||||||
|
if (pending) {
|
||||||
|
pendingPathRef.current = pending;
|
||||||
|
}
|
||||||
|
if (!disposed && pending) {
|
||||||
|
maybeOpenProject(pending);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
// Listen for hot-start paths relayed via the second-instance event.
|
||||||
|
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
||||||
|
maybeOpenProject(nextPath);
|
||||||
|
})
|
||||||
|
.then((dispose) => {
|
||||||
|
if (disposed) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = dispose;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
maybeOpenProject(pendingPathRef.current ?? "");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [client, openProject, serverId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -485,7 +751,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||||
}, [activeServerId, hosts, pathname, router]);
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -519,6 +785,43 @@ function FaviconStatusSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RootStack() {
|
||||||
|
const storeReady = useStoreReady();
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: "none",
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Protected guard={storeReady}>
|
||||||
|
<Stack.Screen name="welcome" />
|
||||||
|
<Stack.Screen name="settings" />
|
||||||
|
<Stack.Screen name="pair-scan" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Screen
|
||||||
|
name="h/[serverId]/workspace/[workspaceId]"
|
||||||
|
getId={({ params }) => {
|
||||||
|
const serverId = getRouteParamValue(params?.serverId);
|
||||||
|
const workspaceId = getRouteParamValue(params?.workspaceId);
|
||||||
|
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NavigationActiveWorkspaceObserver() {
|
function NavigationActiveWorkspaceObserver() {
|
||||||
const navigationRef = useNavigationContainerRef();
|
const navigationRef = useNavigationContainerRef();
|
||||||
|
|
||||||
@@ -539,60 +842,11 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingView({ message }: { message?: string } = {}) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
|
|
||||||
{message ? (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: darkTheme.colors.foregroundMuted,
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MissingDaemonView() {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: darkTheme.colors.foreground,
|
|
||||||
marginTop: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No host configured. Open Settings to add a server URL.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||||
<NavigationActiveWorkspaceObserver />
|
<NavigationActiveWorkspaceObserver />
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
@@ -605,29 +859,9 @@ export default function RootLayout() {
|
|||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<OpenProjectListener />
|
||||||
<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]/sessions" />
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostAgentReadyRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostAgentReadyRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -58,7 +67,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -89,14 +98,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,14 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostIndexRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostIndexRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
@@ -48,11 +57,6 @@ export default function HostIndexRoute() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||||
visibleWorkspaces.sort((left, right) => {
|
|
||||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
|
||||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
|
||||||
return rightTime - leftTime;
|
|
||||||
});
|
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
@@ -68,11 +72,11 @@ export default function HostIndexRoute() {
|
|||||||
|
|
||||||
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(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
router.replace(buildHostOpenProjectRoute(serverId));
|
||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||||
|
|
||||||
export default function HostOpenProjectRoute() {
|
export default function HostOpenProjectRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostOpenProjectRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostOpenProjectRouteContent() {
|
||||||
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 : "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||||
|
|
||||||
export default function HostAgentsRoute() {
|
export default function HostAgentsRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostAgentsRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostAgentsRouteContent() {
|
||||||
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 : "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
|
||||||
export default SettingsScreen;
|
export default function HostSettingsRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<SettingsScreen />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -35,8 +36,17 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const router = useRouter();
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostWorkspaceLayoutContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostWorkspaceLayoutContent() {
|
||||||
|
const rootNavigationState = useRootNavigationState();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
@@ -55,6 +65,9 @@ export default function HostWorkspaceLayout() {
|
|||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!rootNavigationState?.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
@@ -63,19 +76,30 @@ export default function HostWorkspaceLayout() {
|
|||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
const route = openIntent
|
if (openIntent) {
|
||||||
? prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: getOpenIntentTarget(openIntent),
|
target: getOpenIntentTarget(openIntent),
|
||||||
pin: openIntent.kind === "agent",
|
pin: openIntent.kind === "agent",
|
||||||
})
|
});
|
||||||
: buildHostWorkspaceRoute(serverId, workspaceId);
|
}
|
||||||
|
|
||||||
router.replace(route as any);
|
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||||
}, [openValue, router, serverId, workspaceId]);
|
// skips search params). Strip ?open from the browser URL directly so the
|
||||||
|
// address bar reflects the clean workspace route.
|
||||||
|
if (isWeb && typeof window !== "undefined") {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (url.searchParams.has("open")) {
|
||||||
|
url.searchParams.delete("open");
|
||||||
|
window.history.replaceState(null, "", url.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (openValue) {
|
setIntentConsumed(true);
|
||||||
|
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
|
||||||
|
|
||||||
|
if (openValue && !intentConsumed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
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 { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||||
import {
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
shouldRedirectToWelcome,
|
|
||||||
shouldWaitOnStartupRace,
|
|
||||||
WELCOME_ROUTE,
|
|
||||||
} from "@/app-support/index-startup";
|
|
||||||
|
|
||||||
const STARTUP_TIMEOUT_MS = 30_000;
|
const WELCOME_ROUTE = "/welcome";
|
||||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
|
||||||
|
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => {
|
() => {
|
||||||
@@ -33,81 +28,29 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
}
|
}
|
||||||
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 = shouldUseDesktopDaemon();
|
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 ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
||||||
if (
|
router.replace(targetRoute);
|
||||||
!shouldRedirectToWelcome({
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
onlineServerId,
|
|
||||||
hasTimedOut,
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
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 { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -61,7 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderColor: theme.colors.palette.blue[400],
|
borderColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
cornerTL: {
|
cornerTL: {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -148,33 +147,16 @@ export default function PairScanScreen() {
|
|||||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
const sourceServerId = 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 } = useHostMutations();
|
||||||
|
|
||||||
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 pendingNameHostname = useSessionStore(
|
|
||||||
useCallback(
|
|
||||||
(state) => {
|
|
||||||
if (!pendingNameHost) return null;
|
|
||||||
return (
|
|
||||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
|
||||||
pendingNameHost.hostname ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[pendingNameHost],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
if (source === "editHost" && targetServerId) {
|
||||||
@@ -190,7 +172,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
const settingsServerId = sourceServerId ?? serverId;
|
const settingsServerId = sourceServerId ?? serverId;
|
||||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
router.replace(buildHostSettingsRoute(settingsServerId));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId],
|
[router, source, sourceServerId, targetServerId],
|
||||||
@@ -209,7 +191,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
if (sourceServerId) {
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
@@ -217,14 +199,13 @@ export default function PairScanScreen() {
|
|||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router, source, sourceServerId, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") return;
|
if (isWeb) return;
|
||||||
if (permission && permission.granted) return;
|
if (permission && permission.granted) return;
|
||||||
void requestPermission().catch(() => undefined);
|
void requestPermission().catch(() => undefined);
|
||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
if (pendingNameHost) return;
|
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
@@ -248,7 +229,7 @@ export default function PairScanScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client } = await connectToDaemon(
|
const { client, hostname } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -259,13 +240,7 @@ export default function PairScanScreen() {
|
|||||||
);
|
);
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
|
||||||
|
|
||||||
if (isNewHost) {
|
|
||||||
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
returnToSource(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -276,10 +251,10 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
@@ -307,25 +282,6 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{pendingNameHost ? (
|
|
||||||
<NameHostModal
|
|
||||||
visible
|
|
||||||
serverId={pendingNameHost.serverId}
|
|
||||||
hostname={pendingNameHostname}
|
|
||||||
onSkip={() => {
|
|
||||||
const serverId = pendingNameHost.serverId;
|
|
||||||
setPendingNameHost(null);
|
|
||||||
returnToSource(serverId);
|
|
||||||
}}
|
|
||||||
onSave={(label) => {
|
|
||||||
const serverId = pendingNameHost.serverId;
|
|
||||||
void renameHost(serverId, label).finally(() => {
|
|
||||||
setPendingNameHost(null);
|
|
||||||
returnToSource(serverId);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
<Pressable onPress={closeToSource}>
|
<Pressable onPress={closeToSource}>
|
||||||
@@ -359,7 +315,6 @@ 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}>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,55 +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));
|
||||||
}, [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,4 +1,5 @@
|
|||||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export function createNativeFileAttachmentStore() {
|
export function createNativeFileAttachmentStore() {
|
||||||
return createLocalFileAttachmentStore({
|
return createLocalFileAttachmentStore({
|
||||||
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
|
|||||||
if (attachment.storageKey.startsWith("file://")) {
|
if (attachment.storageKey.startsWith("file://")) {
|
||||||
return attachment.storageKey;
|
return attachment.storageKey;
|
||||||
}
|
}
|
||||||
if (attachment.storageKey.startsWith("/")) {
|
if (isAbsolutePath(attachment.storageKey)) {
|
||||||
return `file://${attachment.storageKey}`;
|
if (attachment.storageKey.startsWith("/")) {
|
||||||
|
return `file://${attachment.storageKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNC paths: \\server\share -> file://server/share
|
||||||
|
if (attachment.storageKey.startsWith("\\\\")) {
|
||||||
|
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||||
}
|
}
|
||||||
return attachment.storageKey;
|
return attachment.storageKey;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Platform } from "react-native";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { isDesktop } from "@/desktop/host";
|
|
||||||
import type { AttachmentStore } from "@/attachments/types";
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||||
|
|
||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
if (isDesktop()) {
|
if (isElectronRuntime()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } = await import(
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
"../desktop/attachments/desktop-attachment-store"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pathToFileUri } from "./utils";
|
||||||
|
|
||||||
|
describe("pathToFileUri", () => {
|
||||||
|
it("converts POSIX absolute paths to file URIs", () => {
|
||||||
|
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts Windows drive-letter paths to file URIs", () => {
|
||||||
|
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts UNC paths to host-based file URIs", () => {
|
||||||
|
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through file URIs unchanged", () => {
|
||||||
|
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through relative paths unchanged", () => {
|
||||||
|
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export function generateAttachmentId(): string {
|
export function generateAttachmentId(): string {
|
||||||
return `att_${generateMessageId()}`;
|
return `att_${generateMessageId()}`;
|
||||||
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
|
|||||||
if (path.startsWith("file://")) {
|
if (path.startsWith("file://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAbsolutePath(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.startsWith("/")) {
|
if (path.startsWith("/")) {
|
||||||
return `file://${path}`;
|
return `file://${path}`;
|
||||||
}
|
}
|
||||||
return path;
|
|
||||||
|
// UNC paths: \\server\share -> file://server/share
|
||||||
|
if (path.startsWith("\\\\")) {
|
||||||
|
return `file:${path.replace(/\\/g, "/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `file:///${path.replace(/\\/g, "/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileUriToPath(uri: string): string {
|
export function fileUriToPath(uri: string): string {
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
|
|||||||
function getModeColor(modeId?: string): string {
|
function getModeColor(modeId?: string): string {
|
||||||
if (!modeId) return "#9ca3af"; // gray
|
if (!modeId) return "#9ca3af"; // gray
|
||||||
|
|
||||||
// Color based on common mode types
|
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
|
||||||
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
|
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
|
||||||
if (modeId.includes("code")) return "#22c55e"; // green - writes code
|
return "#3b82f6"; // blue - build/auto
|
||||||
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
|
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
|
||||||
|
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
|
||||||
|
return "#22c55e"; // green - safest
|
||||||
|
|
||||||
return "#9ca3af"; // gray - unknown
|
return "#9ca3af"; // gray - unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type BottomSheetBackgroundProps,
|
type BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
@@ -28,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
maxHeight: "85%",
|
maxHeight: "85%",
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -54,11 +58,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flex: 1,
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopContent: {
|
desktopContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -115,7 +121,7 @@ export function AdaptiveModalSheet({
|
|||||||
testID,
|
testID,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
@@ -211,7 +217,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// On web, use portal to overlay root for consistent stacking with toasts
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
@@ -235,7 +241,7 @@ export function AdaptiveModalSheet({
|
|||||||
*/
|
*/
|
||||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||||
function AdaptiveTextInput(props, ref) {
|
function AdaptiveTextInput(props, ref) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, Text, View, Platform } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
option: {
|
option: {
|
||||||
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{Platform.OS !== "web" ? (
|
{isNative ? (
|
||||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Link2 } from "lucide-react-native";
|
import { Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -151,7 +152,7 @@ export function AddHostModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ export function AddHostModal({
|
|||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
label: hostname ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
|
||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
|
|||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -168,7 +169,7 @@ export function SelectField({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -537,14 +538,10 @@ export function AgentConfigRow({
|
|||||||
}, [modeOptions]);
|
}, [modeOptions]);
|
||||||
|
|
||||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
return models.map((model) => ({
|
||||||
for (const model of models) {
|
id: model.id,
|
||||||
opts.push({
|
label: model.label,
|
||||||
id: model.id,
|
}));
|
||||||
label: model.label,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
|
||||||
@@ -560,7 +557,11 @@ export function AgentConfigRow({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||||
|
|
||||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
const selectedModeVisuals = getModeVisuals(
|
||||||
|
selectedProvider,
|
||||||
|
effectiveSelectedMode,
|
||||||
|
providerDefinitions,
|
||||||
|
);
|
||||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||||
|
|
||||||
@@ -586,7 +587,7 @@ export function AgentConfigRow({
|
|||||||
title="Select model"
|
title="Select model"
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
options={modelSelectOptions}
|
options={modelSelectOptions}
|
||||||
placeholder="Auto"
|
placeholder={isModelLoading ? "Loading..." : "Select model"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
@@ -777,10 +778,9 @@ export function ModelDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel = selectedModel
|
const selectedLabel =
|
||||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||||
: "Automatic";
|
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
|
||||||
const helperText = error
|
const helperText = error
|
||||||
? undefined
|
? undefined
|
||||||
: isLoading
|
: isLoading
|
||||||
@@ -790,34 +790,20 @@ export function ModelDropdown({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [
|
return models.map((model) => ({
|
||||||
{
|
id: model.id,
|
||||||
id: "",
|
label: model.label,
|
||||||
label: "Automatic (provider default)",
|
description: model.description,
|
||||||
description: "Let the assistant pick the recommended model.",
|
}));
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const model of models) {
|
|
||||||
opts.push({
|
|
||||||
id: model.id,
|
|
||||||
label: model.label,
|
|
||||||
description: model.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === "") {
|
onSelect(id);
|
||||||
onClear();
|
|
||||||
} else {
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onClear, onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1464,11 +1450,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
shadowColor: "#000",
|
...theme.shadow.md,
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.2,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
type DraftAgentStatusBarProps,
|
type DraftAgentStatusBarProps,
|
||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +49,8 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,6 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
|||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
isInputActive: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
@@ -73,6 +78,8 @@ interface AgentInputAreaProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
|
/** Callback to expose a focus function to parent components (desktop only). */
|
||||||
|
onFocusInput?: (focus: () => void) => void;
|
||||||
/** Optional draft context for listing commands before an agent exists. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
commandDraftConfig?: DraftCommandConfig;
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||||
@@ -91,6 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
|||||||
export function AgentInputArea({
|
export function AgentInputArea({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
isInputActive,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
@@ -101,6 +109,7 @@ export function AgentInputArea({
|
|||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
|
onFocusInput,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
onComposerHeightChange,
|
onComposerHeightChange,
|
||||||
@@ -110,10 +119,8 @@ export function AgentInputArea({
|
|||||||
}: AgentInputAreaProps) {
|
}: AgentInputAreaProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isScreenFocused = useIsFocused();
|
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
@@ -127,7 +134,18 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
|
||||||
|
const agentState = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
|
return {
|
||||||
|
status: agent?.status ?? null,
|
||||||
|
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||||
|
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const queuedMessagesRaw = useSessionStore((state) =>
|
const queuedMessagesRaw = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||||
@@ -138,10 +156,8 @@ export function AgentInputArea({
|
|||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||||
|
|
||||||
const isDesktopWebBreakpoint =
|
const isMobile = useIsCompactFormFactor();
|
||||||
Platform.OS === "web" &&
|
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
||||||
UnistylesRuntime.breakpoint !== "xs" &&
|
|
||||||
UnistylesRuntime.breakpoint !== "sm";
|
|
||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
@@ -201,6 +217,21 @@ export function AgentInputArea({
|
|||||||
onAddImages?.(addImages);
|
onAddImages?.(addImages);
|
||||||
}, [addImages, onAddImages]);
|
}, [addImages, onAddImages]);
|
||||||
|
|
||||||
|
const focusInput = useCallback(() => {
|
||||||
|
if (isNative) return;
|
||||||
|
focusWithRetries({
|
||||||
|
focus: () => messageInputRef.current?.focus(),
|
||||||
|
isFocused: () => {
|
||||||
|
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
||||||
|
return el != null && document.activeElement === el;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFocusInput?.(focusInput);
|
||||||
|
}, [focusInput, onFocusInput]);
|
||||||
|
|
||||||
const submitMessage = useCallback(
|
const submitMessage = useCallback(
|
||||||
async (text: string, images?: ImageAttachment[]) => {
|
async (text: string, images?: ImageAttachment[]) => {
|
||||||
onMessageSent?.();
|
onMessageSent?.();
|
||||||
@@ -260,7 +291,6 @@ export function AgentInputArea({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
await client.sendAgentMessage(agentId, text, {
|
await client.sendAgentMessage(agentId, text, {
|
||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
@@ -274,41 +304,8 @@ export function AgentInputArea({
|
|||||||
onSubmitMessageRef.current = onSubmitMessage;
|
onSubmitMessageRef.current = onSubmitMessage;
|
||||||
}, [onSubmitMessage]);
|
}, [onSubmitMessage]);
|
||||||
|
|
||||||
const isAgentRunning = agent?.status === "running";
|
const isAgentRunning = agentState.status === "running";
|
||||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
const hasAgent = agentState.status !== null;
|
||||||
|
|
||||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
|
||||||
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
|
||||||
useEffect(() => {
|
|
||||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
|
||||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
|
||||||
if (isProcessing && !isAgentRunning) {
|
|
||||||
prevIsAgentRunningRef.current = false;
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasRunning = prevIsAgentRunningRef.current;
|
|
||||||
let shouldClearProcessing = false;
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
const hasEnteredRunning = !wasRunning && isAgentRunning;
|
|
||||||
const hasFreshRunningUpdateWhileRunning =
|
|
||||||
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
|
|
||||||
const hasStoppedRunning = wasRunning && !isAgentRunning;
|
|
||||||
|
|
||||||
shouldClearProcessing =
|
|
||||||
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIsAgentRunningRef.current = isAgentRunning;
|
|
||||||
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
|
||||||
|
|
||||||
if (shouldClearProcessing) {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const updateQueue = useCallback(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||||
@@ -350,7 +347,7 @@ export function AgentInputArea({
|
|||||||
message,
|
message,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
forceSend,
|
forceSend,
|
||||||
isAgentRunning: agent?.status === "running",
|
isAgentRunning: agentState.status === "running",
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
@@ -424,13 +421,17 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isScreenFocused) {
|
if (!isInputActive) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
|
case "message-input.send":
|
||||||
|
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
||||||
|
case "message-input.dictation-confirm":
|
||||||
|
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||||
case "message-input.focus":
|
case "message-input.focus":
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -460,21 +461,23 @@ export function AgentInputArea({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isScreenFocused],
|
[isInputActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: keyboardHandlerIdRef.current,
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
actions: [
|
actions: [
|
||||||
"message-input.focus",
|
"message-input.focus",
|
||||||
|
"message-input.send",
|
||||||
"message-input.dictation-toggle",
|
"message-input.dictation-toggle",
|
||||||
"message-input.dictation-cancel",
|
"message-input.dictation-cancel",
|
||||||
|
"message-input.dictation-confirm",
|
||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
enabled: isScreenFocused,
|
enabled: isInputActive,
|
||||||
priority: isMessageInputFocused ? 200 : 100,
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
isActive: () => isScreenFocused,
|
isActive: () => isInputActive,
|
||||||
handle: handleKeyboardAction,
|
handle: handleKeyboardAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -483,7 +486,7 @@ export function AgentInputArea({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleCancelAgent() {
|
function handleCancelAgent() {
|
||||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
if (!isAgentRunning || isCancellingAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isConnected || !client) {
|
if (!isConnected || !client) {
|
||||||
@@ -497,7 +500,7 @@ export function AgentInputArea({
|
|||||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
if (!voice || !isConnected || !agent) {
|
if (!voice || !isConnected || !hasAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (voice.isVoiceSwitching) {
|
if (voice.isVoiceSwitching) {
|
||||||
@@ -514,7 +517,7 @@ export function AgentInputArea({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [agent, agentId, isConnected, serverId, toast, voice]);
|
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -594,19 +597,19 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
{dictationCancelKeys ? (
|
{dictationCancelKeys ? (
|
||||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<View style={styles.rightControls}>
|
<View style={styles.rightControls}>
|
||||||
{!isVoiceModeForAgent && agent ? (
|
{!isVoiceModeForAgent && hasAgent ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleToggleRealtimeVoice}
|
onPress={handleToggleRealtimeVoice}
|
||||||
@@ -639,11 +642,28 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasContextWindowMeter =
|
||||||
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
|
typeof agentState.contextWindowUsedTokens === "number";
|
||||||
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
|
|
||||||
|
const beforeVoiceContent = (
|
||||||
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
|
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||||
|
<ContextWindowMeter
|
||||||
|
maxTokens={contextWindowMaxTokens}
|
||||||
|
usedTokens={contextWindowUsedTokens}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
) : (
|
) : (
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -716,12 +736,14 @@ export function AgentInputArea({
|
|||||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isScreenFocused={isScreenFocused}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
|
defaultSendBehavior={appSettings.sendBehavior}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
@@ -793,6 +815,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
contextWindowMeterSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
realtimeVoiceButton: {
|
realtimeVoiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
|
|||||||
try {
|
try {
|
||||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||||
input.clearDraft("sent");
|
input.clearDraft("sent");
|
||||||
|
input.setIsProcessing(false);
|
||||||
return "submitted";
|
return "submitted";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
input.onSubmitError?.(error);
|
input.onSubmitError?.(error);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
@@ -214,7 +215,7 @@ export function AgentList({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
@@ -240,7 +241,7 @@ export function AgentList({
|
|||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
@@ -257,7 +258,8 @@ export function AgentList({
|
|||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void actionClient.archiveAgent(actionAgent.id);
|
// Timeout errors are swallowed — the daemon will still process the archive
|
||||||
|
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
|
||||||
|
|
||||||
|
describe("isProviderModelsQueryLoading", () => {
|
||||||
|
it("does not treat a disabled pending query as loading", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const observer = new QueryObserver(queryClient, {
|
||||||
|
queryKey: ["providerModels", "server-1", "__missing_provider__"],
|
||||||
|
enabled: false,
|
||||||
|
queryFn: async () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = observer.getCurrentResult();
|
||||||
|
|
||||||
|
expect(result.isPending).toBe(true);
|
||||||
|
expect(result.isLoading).toBe(false);
|
||||||
|
expect(result.isFetching).toBe(false);
|
||||||
|
expect(isProviderModelsQueryLoading(result)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an active fetch as loading", () => {
|
||||||
|
expect(
|
||||||
|
isProviderModelsQueryLoading({
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
interface ProviderModelsQueryState {
|
||||||
|
isFetching: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
|
||||||
|
return input.isLoading || input.isFetching;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
getFeatureHighlightColor,
|
||||||
|
getFeatureTooltip,
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
@@ -13,15 +15,40 @@ describe("getStatusSelectorHint", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("feature metadata helpers", () => {
|
||||||
|
it("prefers explicit feature tooltip copy", () => {
|
||||||
|
expect(
|
||||||
|
getFeatureTooltip({
|
||||||
|
label: "Plan",
|
||||||
|
tooltip: "Toggle plan mode",
|
||||||
|
}),
|
||||||
|
).toBe("Toggle plan mode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the feature label when no tooltip is provided", () => {
|
||||||
|
expect(
|
||||||
|
getFeatureTooltip({
|
||||||
|
label: "Custom",
|
||||||
|
}),
|
||||||
|
).toBe("Custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps feature highlight colors by feature id", () => {
|
||||||
|
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||||
|
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||||
|
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty and default values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
expect(normalizeModelId(" default ")).toBeNull();
|
|
||||||
expect(normalizeModelId(undefined)).toBeNull();
|
expect(normalizeModelId(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns trimmed model ids", () => {
|
it("returns trimmed model ids", () => {
|
||||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||||
|
expect(normalizeModelId(" default ")).toBe("default");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,4 +96,50 @@ describe("resolveAgentModelSelection", () => {
|
|||||||
expect(selection.selectedThinkingId).toBe("high");
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
expect(selection.displayThinking).toBe("High");
|
expect(selection.displayThinking).toBe("High");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the provider default model label instead of Auto", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
provider: "codex",
|
||||||
|
label: "Model A",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: null,
|
||||||
|
configuredModelId: null,
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.displayModel).toBe("Model A");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured model when runtime model is not in the model list", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
provider: "claude",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: "claude-sonnet-4-6-20260101",
|
||||||
|
configuredModelId: "default",
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.activeModelId).toBe("default");
|
||||||
|
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
|
||||||
|
expect(selection.selectedThinkingId).toBe("low");
|
||||||
|
expect(selection.displayThinking).toBe("Low");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||||
|
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
||||||
|
|
||||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||||
switch (selector) {
|
switch (selector) {
|
||||||
@@ -15,12 +16,27 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
|||||||
|
|
||||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === "default") {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
||||||
|
return feature.tooltip ?? feature.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||||
|
switch (featureId) {
|
||||||
|
case "fast_mode":
|
||||||
|
return "yellow";
|
||||||
|
case "plan_mode":
|
||||||
|
return "blue";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAgentModelSelection(input: {
|
export function resolveAgentModelSelection(input: {
|
||||||
models: AgentModelDefinition[] | null;
|
models: AgentModelDefinition[] | null;
|
||||||
runtimeModelId: string | null | undefined;
|
runtimeModelId: string | null | undefined;
|
||||||
@@ -30,25 +46,32 @@ export function resolveAgentModelSelection(input: {
|
|||||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
const runtimeSelectedModel =
|
||||||
|
models && normalizedRuntimeModelId
|
||||||
|
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
|
||||||
|
: null;
|
||||||
|
const preferredModelId =
|
||||||
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
|
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
: null;
|
: fallbackModel;
|
||||||
|
|
||||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
const displayModel =
|
||||||
|
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
|
||||||
|
|
||||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||||
const selectedThinkingId =
|
const resolvedThinkingId =
|
||||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||||
? explicitThinkingOptionId
|
? explicitThinkingOptionId
|
||||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||||
const selectedThinking =
|
const selectedThinking =
|
||||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
|
||||||
const displayThinking =
|
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
|
||||||
selectedThinking?.label ??
|
const selectedThinkingId = effectiveThinking?.id ?? null;
|
||||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||||
import Markdown from "react-native-markdown-display";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -27,6 +27,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
|
SpeakMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -36,10 +37,14 @@ import {
|
|||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
|
import { PlanCard } from "./plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
import type { Agent } from "@/contexts/session-context";
|
AgentPermissionAction,
|
||||||
|
AgentPermissionResponse,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -59,9 +64,7 @@ import {
|
|||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -70,6 +73,7 @@ import {
|
|||||||
WORKING_INDICATOR_CYCLE_MS,
|
WORKING_INDICATOR_CYCLE_MS,
|
||||||
WORKING_INDICATOR_OFFSETS,
|
WORKING_INDICATOR_OFFSETS,
|
||||||
} from "@/utils/working-indicator";
|
} from "@/utils/working-indicator";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -82,7 +86,7 @@ export interface AgentStreamViewHandle {
|
|||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agent: Agent;
|
agent: AgentScreenAgent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
@@ -107,7 +111,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveStreamRenderStrategy({
|
resolveStreamRenderStrategy({
|
||||||
@@ -180,7 +184,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "file", path: normalized.file },
|
target: { kind: "file", path: normalized.file },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
tail: streamItems,
|
tail: streamItems,
|
||||||
head: streamHead ?? [],
|
head: streamHead ?? [],
|
||||||
platform: Platform.OS === "web" ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
@@ -269,44 +273,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[looseGap, tightGap],
|
[looseGap, tightGap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DEBUG: track when render callback deps change
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = debugStreamPrevRef.current;
|
|
||||||
const curr: Record<string, unknown> = {
|
|
||||||
// handleInlinePathPress deps (line 196-205)
|
|
||||||
"hip.agent.cwd": agent.cwd,
|
|
||||||
"hip.openFileExplorer": openFileExplorer,
|
|
||||||
"hip.requestDirectoryListing": requestDirectoryListing,
|
|
||||||
"hip.resolvedServerId": resolvedServerId,
|
|
||||||
"hip.router": router,
|
|
||||||
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
|
|
||||||
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
|
|
||||||
"hip.workspaceId": workspaceId,
|
|
||||||
// top-level deps
|
|
||||||
handleInlinePathPress,
|
|
||||||
"agent.status": agent.status,
|
|
||||||
streamRenderStrategy,
|
|
||||||
getGapBetween,
|
|
||||||
streamItems,
|
|
||||||
"streamItems.length": streamItems.length,
|
|
||||||
streamHead,
|
|
||||||
baseRenderModel,
|
|
||||||
};
|
|
||||||
const changed: string[] = [];
|
|
||||||
for (const key of Object.keys(curr)) {
|
|
||||||
if (!Object.is(prev[key], curr[key])) {
|
|
||||||
changed.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed.length > 0 && Object.keys(prev).length > 0) {
|
|
||||||
console.log("[AgentStreamView] deps changed:", changed.join(", "));
|
|
||||||
}
|
|
||||||
debugStreamPrevRef.current = curr;
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
(
|
(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
@@ -366,9 +332,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
client={client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "thought": {
|
case "thought": {
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -400,6 +367,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
if (payload.source === "agent") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.name === "speak" &&
|
||||||
|
data.detail.type === "unknown" &&
|
||||||
|
typeof data.detail.input === "string" &&
|
||||||
|
data.detail.input.trim()
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
@@ -732,12 +711,35 @@ function PermissionRequestCard({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const { request } = permission;
|
const { request } = permission;
|
||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
const description = request.description ?? "";
|
||||||
|
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||||
|
if (request.kind === "question") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||||
|
return request.actions;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Deny",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accept",
|
||||||
|
label: isPlanRequest ? "Implement" : "Accept",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [isPlanRequest, request]);
|
||||||
|
|
||||||
const planMarkdown = useMemo(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -755,90 +757,6 @@ function PermissionRequestCard({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
|
||||||
return {
|
|
||||||
text: (
|
|
||||||
node: any,
|
|
||||||
_children: React.ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
inheritedStyles: any = {},
|
|
||||||
) => (
|
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
|
||||||
{node.content}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
textgroup: (
|
|
||||||
node: any,
|
|
||||||
children: React.ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
inheritedStyles: any = {},
|
|
||||||
) => (
|
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
code_block: (
|
|
||||||
node: any,
|
|
||||||
_children: React.ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
inheritedStyles: any = {},
|
|
||||||
) => (
|
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
|
||||||
{node.content}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
fence: (
|
|
||||||
node: any,
|
|
||||||
_children: React.ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
inheritedStyles: any = {},
|
|
||||||
) => (
|
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
|
||||||
{node.content}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
code_inline: (
|
|
||||||
node: any,
|
|
||||||
_children: React.ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
inheritedStyles: any = {},
|
|
||||||
) => (
|
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
|
||||||
{node.content}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
|
||||||
<View key={node.key} style={styles.bullet_list}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
|
||||||
<View key={node.key} style={styles.ordered_list}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
|
|
||||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
|
||||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
|
||||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
|
||||||
<Text style={iconStyle}>{marker}</Text>
|
|
||||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const permissionMutation = useMutation({
|
const permissionMutation = useMutation({
|
||||||
mutationFn: async (input: {
|
mutationFn: async (input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -862,11 +780,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingAction(null);
|
setRespondingActionId(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -880,6 +798,24 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[permission.agentId, permission.request.id, respondToPermission],
|
||||||
);
|
);
|
||||||
|
const handleActionPress = useCallback(
|
||||||
|
(action: AgentPermissionAction) => {
|
||||||
|
setRespondingActionId(action.id);
|
||||||
|
if (action.behavior === "allow") {
|
||||||
|
handleResponse({
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: action.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleResponse({
|
||||||
|
behavior: "deny",
|
||||||
|
selectedActionId: action.id,
|
||||||
|
message: "Denied by user",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleResponse],
|
||||||
|
);
|
||||||
|
|
||||||
if (request.kind === "question") {
|
if (request.kind === "question") {
|
||||||
return (
|
return (
|
||||||
@@ -891,6 +827,79 @@ function PermissionRequestCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
testID="permission-request-question"
|
||||||
|
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
||||||
|
>
|
||||||
|
How would you like to proceed?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
permissionStyles.optionsContainer,
|
||||||
|
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{resolvedActions.map((action) => {
|
||||||
|
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||||
|
const isPrimary = action.variant === "primary";
|
||||||
|
const isRespondingAction = respondingActionId === action.id;
|
||||||
|
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
const iconColor = textColor;
|
||||||
|
const Icon = action.behavior === "allow" ? Check : X;
|
||||||
|
const testID =
|
||||||
|
action.behavior === "deny"
|
||||||
|
? "permission-request-deny"
|
||||||
|
: action.id === "accept" || action.id === "implement"
|
||||||
|
? "permission-request-accept"
|
||||||
|
: `permission-request-action-${action.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={action.id}
|
||||||
|
testID={testID}
|
||||||
|
style={({ pressed, hovered = false }) => [
|
||||||
|
permissionStyles.optionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
|
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
|
]}
|
||||||
|
onPress={() => handleActionPress(action)}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
{isRespondingAction ? (
|
||||||
|
<ActivityIndicator size="small" color={textColor} />
|
||||||
|
) : (
|
||||||
|
<View style={permissionStyles.optionContent}>
|
||||||
|
<Icon size={14} color={iconColor} />
|
||||||
|
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||||
|
{action.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlanRequest && planMarkdown) {
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
text={planMarkdown}
|
||||||
|
footer={footer}
|
||||||
|
disableOuterSpacing
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -910,16 +919,7 @@ function PermissionRequestCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{planMarkdown ? (
|
{planMarkdown ? (
|
||||||
<View style={permissionStyles.section}>
|
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
|
||||||
{!isPlanRequest ? (
|
|
||||||
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
|
|
||||||
Proposed plan
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
|
||||||
{planMarkdown}
|
|
||||||
</Markdown>
|
|
||||||
</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isPlanRequest ? (
|
{!isPlanRequest ? (
|
||||||
@@ -935,78 +935,7 @@ function PermissionRequestCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Text
|
{footer}
|
||||||
testID="permission-request-question"
|
|
||||||
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
|
||||||
>
|
|
||||||
How would you like to proceed?
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
permissionStyles.optionsContainer,
|
|
||||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
testID="permission-request-deny"
|
|
||||||
style={({ pressed, hovered = false }) => [
|
|
||||||
permissionStyles.optionButton,
|
|
||||||
{
|
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
setRespondingAction("deny");
|
|
||||||
handleResponse({
|
|
||||||
behavior: "deny",
|
|
||||||
message: "Denied by user",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
{respondingAction === "deny" ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={permissionStyles.optionContent}>
|
|
||||||
<X size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
|
||||||
Deny
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
testID="permission-request-accept"
|
|
||||||
style={({ pressed, hovered = false }) => [
|
|
||||||
permissionStyles.optionButton,
|
|
||||||
{
|
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
setRespondingAction("accept");
|
|
||||||
handleResponse({ behavior: "allow" });
|
|
||||||
}}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
{respondingAction === "accept" ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
|
||||||
) : (
|
|
||||||
<View style={permissionStyles.optionContent}>
|
|
||||||
<Check size={14} color={theme.colors.foreground} />
|
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
|
||||||
Accept
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1127,14 +1056,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
shadowColor: "#000",
|
...theme.shadow.sm,
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.25,
|
|
||||||
shadowRadius: 3.84,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
},
|
||||||
scrollToBottomIcon: {
|
scrollToBottomIcon: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
clearAssistantImageMetadataCache,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "@/utils/assistant-image-metadata";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
|
|||||||
|
|
||||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses cached assistant image metadata when available", () => {
|
||||||
|
clearAssistantImageMetadataCache();
|
||||||
|
setAssistantImageMetadata(
|
||||||
|
{
|
||||||
|
source: "https://example.com/tall.png",
|
||||||
|
},
|
||||||
|
{ width: 800, height: 1600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const item: StreamItem = {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: "a-image",
|
||||||
|
text: "Look at this\n\n",
|
||||||
|
timestamp: createTimestamp(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("web virtualization test overrides", () => {
|
describe("web virtualization test overrides", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
|
||||||
|
|
||||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||||
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
|||||||
case "user_message":
|
case "user_message":
|
||||||
return item.images && item.images.length > 0 ? 220 : 96;
|
return item.images && item.images.length > 0 ? 220 : 96;
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return 220;
|
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return 136;
|
return 136;
|
||||||
case "thought":
|
case "thought":
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||||
|
|
||||||
|
interface BranchSwitcherProps {
|
||||||
|
currentBranchName: string | null;
|
||||||
|
title: string;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
isGitCheckout: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchSwitcher({
|
||||||
|
currentBranchName,
|
||||||
|
title,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
isGitCheckout,
|
||||||
|
}: BranchSwitcherProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const toast = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||||
|
client,
|
||||||
|
normalizedServerId: serverId,
|
||||||
|
normalizedWorkspaceId: workspaceId,
|
||||||
|
currentBranchName,
|
||||||
|
isGitCheckout,
|
||||||
|
isConnected,
|
||||||
|
toast,
|
||||||
|
queryClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentBranchName) {
|
||||||
|
return (
|
||||||
|
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
testID="workspace-header-branch-switcher"
|
||||||
|
onPress={() => setIsOpen(true)}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.branchSwitcherTrigger,
|
||||||
|
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||||
|
>
|
||||||
|
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
<Combobox
|
||||||
|
options={branchOptions}
|
||||||
|
value={currentBranchName}
|
||||||
|
onSelect={handleBranchSelect}
|
||||||
|
searchable
|
||||||
|
placeholder="Switch branch..."
|
||||||
|
searchPlaceholder="Filter branches..."
|
||||||
|
emptyText="No branches found."
|
||||||
|
title="Switch branch"
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
desktopPreventInitialFlash
|
||||||
|
desktopMinWidth={280}
|
||||||
|
renderOption={({ option, selected, active, onPress }) => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={option.id}
|
||||||
|
label={option.label}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: {
|
||||||
|
xs: "400",
|
||||||
|
md: "300",
|
||||||
|
},
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
branchSwitcherTrigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
branchSwitcherTriggerHovered: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
buildModelRows,
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
matchesSearch,
|
||||||
|
resolveProviderLabel,
|
||||||
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
|
describe("combined model selector helpers", () => {
|
||||||
|
const providerDefinitions = [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "sonnet-4.6",
|
||||||
|
label: "Sonnet 4.6",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const codexModels: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps enough data to search by model and provider name", async () => {
|
||||||
|
const rows = buildModelRows(
|
||||||
|
providerDefinitions,
|
||||||
|
new Map([
|
||||||
|
["claude", claudeModels],
|
||||||
|
["codex", codexModels],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
providerLabel: "Claude",
|
||||||
|
modelLabel: "Sonnet 4.6",
|
||||||
|
modelId: "sonnet-4.6",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
providerLabel: "Codex",
|
||||||
|
modelLabel: "GPT-5.4",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||||
|
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||||
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
|
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable, Platform } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
type GestureResponderEvent,
|
||||||
|
} from "react-native";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
|
import {
|
||||||
|
buildModelRows,
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
matchesSearch,
|
||||||
|
resolveProviderLabel,
|
||||||
|
type SelectorModelRow,
|
||||||
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
const INLINE_MODEL_THRESHOLD = 8;
|
// TODO: this should be configured per provider in the provider manifest
|
||||||
|
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||||
|
|
||||||
type DrillDownView = { provider: string };
|
type SelectorView =
|
||||||
|
| { kind: "all" }
|
||||||
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
@@ -18,9 +41,467 @@ interface CombinedModelSelectorProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
canSelectProvider?: (provider: string) => boolean;
|
||||||
|
favoriteKeys?: Set<string>;
|
||||||
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
|
renderTrigger?: (input: {
|
||||||
|
selectedModelLabel: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectorContentProps {
|
||||||
|
view: SelectorView;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
|
selectedProvider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
favoriteKeys: Set<string>;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
canSelectProvider: (provider: string) => boolean;
|
||||||
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
|
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchQuery(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function partitionRows(
|
||||||
|
rows: SelectorModelRow[],
|
||||||
|
favoriteKeys: Set<string>,
|
||||||
|
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
|
||||||
|
const favoriteRows: SelectorModelRow[] = [];
|
||||||
|
const regularRows: SelectorModelRow[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (favoriteKeys.has(row.favoriteKey)) {
|
||||||
|
favoriteRows.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
regularRows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { favoriteRows, regularRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFavoritesFirst(
|
||||||
|
rows: SelectorModelRow[],
|
||||||
|
favoriteKeys: Set<string>,
|
||||||
|
): SelectorModelRow[] {
|
||||||
|
const favorites: SelectorModelRow[] = [];
|
||||||
|
const rest: SelectorModelRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (favoriteKeys.has(row.favoriteKey)) {
|
||||||
|
favorites.push(row);
|
||||||
|
} else {
|
||||||
|
rest.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...favorites, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRowsByProvider(
|
||||||
|
rows: SelectorModelRow[],
|
||||||
|
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = grouped.get(row.provider);
|
||||||
|
if (existing) {
|
||||||
|
existing.rows.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.set(row.provider, {
|
||||||
|
providerId: row.provider,
|
||||||
|
providerLabel: row.providerLabel,
|
||||||
|
rows: [row],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRow({
|
||||||
|
row,
|
||||||
|
isSelected,
|
||||||
|
isFavorite,
|
||||||
|
disabled = false,
|
||||||
|
elevated = false,
|
||||||
|
onPress,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
row: SelectorModelRow;
|
||||||
|
isSelected: boolean;
|
||||||
|
isFavorite: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
elevated?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const ProviderIcon = getProviderIcon(row.provider);
|
||||||
|
|
||||||
|
const handleToggleFavorite = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleFavorite?.(row.provider, row.modelId);
|
||||||
|
},
|
||||||
|
[onToggleFavorite, row.modelId, row.provider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label={row.modelLabel}
|
||||||
|
description={showDescription ? row.description : undefined}
|
||||||
|
selected={isSelected}
|
||||||
|
disabled={disabled}
|
||||||
|
elevated={elevated}
|
||||||
|
onPress={onPress}
|
||||||
|
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||||
|
trailingSlot={
|
||||||
|
onToggleFavorite && !disabled ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggleFavorite}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.favoriteButton,
|
||||||
|
hovered && styles.favoriteButtonHovered,
|
||||||
|
pressed && styles.favoriteButtonPressed,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
||||||
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<Star
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
isFavorite
|
||||||
|
? theme.colors.palette.amber[500]
|
||||||
|
: hovered
|
||||||
|
? theme.colors.foregroundMuted
|
||||||
|
: theme.colors.border
|
||||||
|
}
|
||||||
|
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FavoritesSection({
|
||||||
|
favoriteRows,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
favoriteKeys,
|
||||||
|
onSelect,
|
||||||
|
canSelectProvider,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
favoriteRows: SelectorModelRow[];
|
||||||
|
selectedProvider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
favoriteKeys: Set<string>;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
canSelectProvider: (provider: string) => boolean;
|
||||||
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
if (favoriteRows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.favoritesContainer}>
|
||||||
|
<View style={styles.sectionHeading}>
|
||||||
|
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||||
|
</View>
|
||||||
|
{favoriteRows.map((row) => (
|
||||||
|
<ModelRow
|
||||||
|
key={row.favoriteKey}
|
||||||
|
row={row}
|
||||||
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||||
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||||
|
disabled={!canSelectProvider(row.provider)}
|
||||||
|
elevated
|
||||||
|
onPress={() => onSelect(row.provider, row.modelId)}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupedProviderRows({
|
||||||
|
providerDefinitions,
|
||||||
|
groupedRows,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
favoriteKeys,
|
||||||
|
onSelect,
|
||||||
|
canSelectProvider,
|
||||||
|
onToggleFavorite,
|
||||||
|
onDrillDown,
|
||||||
|
viewKind,
|
||||||
|
}: {
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||||
|
selectedProvider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
favoriteKeys: Set<string>;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
canSelectProvider: (provider: string) => boolean;
|
||||||
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
viewKind: SelectorView["kind"];
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{groupedRows.map((group, index) => {
|
||||||
|
const providerDefinition = providerDefinitions.find(
|
||||||
|
(definition) => definition.id === group.providerId,
|
||||||
|
);
|
||||||
|
const ProvIcon = getProviderIcon(group.providerId);
|
||||||
|
const isInline = viewKind === "provider";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={group.providerId}>
|
||||||
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
|
{isInline ? (
|
||||||
|
<>
|
||||||
|
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
||||||
|
<ModelRow
|
||||||
|
key={row.favoriteKey}
|
||||||
|
row={row}
|
||||||
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||||
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||||
|
disabled={!canSelectProvider(row.provider)}
|
||||||
|
onPress={() => onSelect(row.provider, row.modelId)}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.drillDownRow,
|
||||||
|
hovered && styles.drillDownRowHovered,
|
||||||
|
pressed && styles.drillDownRowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||||
|
<View style={styles.drillDownTrailing}>
|
||||||
|
<Text style={styles.drillDownCount}>
|
||||||
|
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderSearchInput({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
autoFocus = false,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.providerSearchContainer}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<InputComponent
|
||||||
|
ref={inputRef as any}
|
||||||
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
|
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
||||||
|
placeholder="Search models..."
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectorContent({
|
||||||
|
view,
|
||||||
|
providerDefinitions,
|
||||||
|
allProviderModels,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
favoriteKeys,
|
||||||
|
onSelect,
|
||||||
|
canSelectProvider,
|
||||||
|
onToggleFavorite,
|
||||||
|
onDrillDown,
|
||||||
|
}: SelectorContentProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const allRows = useMemo(
|
||||||
|
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||||
|
[allProviderModels, providerDefinitions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopedRows = useMemo(() => {
|
||||||
|
if (view.kind === "provider") {
|
||||||
|
return allRows.filter((row) => row.provider === view.providerId);
|
||||||
|
}
|
||||||
|
return allRows;
|
||||||
|
}, [allRows, view]);
|
||||||
|
|
||||||
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
|
|
||||||
|
const visibleRows = useMemo(
|
||||||
|
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
|
||||||
|
[normalizedQuery, scopedRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { favoriteRows, regularRows } = useMemo(
|
||||||
|
() => partitionRows(visibleRows, favoriteKeys),
|
||||||
|
[favoriteKeys, visibleRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group ALL visible rows by provider — favorites are a cross-cutting view,
|
||||||
|
// not a partition. A model being favorited doesn't remove it from its provider.
|
||||||
|
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
||||||
|
|
||||||
|
// When searching at Level 1, filter grouped rows to only providers whose name or models match
|
||||||
|
const filteredGroupedRows = useMemo(() => {
|
||||||
|
if (view.kind === "provider" || !normalizedQuery) {
|
||||||
|
return allGroupedRows;
|
||||||
|
}
|
||||||
|
return allGroupedRows.filter(
|
||||||
|
(group) =>
|
||||||
|
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
|
||||||
|
);
|
||||||
|
}, [allGroupedRows, normalizedQuery, view.kind]);
|
||||||
|
|
||||||
|
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{view.kind === "all" ? (
|
||||||
|
<FavoritesSection
|
||||||
|
favoriteRows={favoriteRows}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onSelect={onSelect}
|
||||||
|
canSelectProvider={canSelectProvider}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filteredGroupedRows.length > 0 ? (
|
||||||
|
<GroupedProviderRows
|
||||||
|
providerDefinitions={providerDefinitions}
|
||||||
|
groupedRows={filteredGroupedRows}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onSelect={onSelect}
|
||||||
|
canSelectProvider={canSelectProvider}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
viewKind={view.kind}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasResults ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderBackButton({
|
||||||
|
providerId,
|
||||||
|
providerLabel,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
providerId: string;
|
||||||
|
providerLabel: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const ProviderIcon = getProviderIcon(providerId);
|
||||||
|
|
||||||
|
if (!onBack) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack}
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.backButton,
|
||||||
|
hovered && styles.backButtonHovered,
|
||||||
|
pressed && styles.backButtonPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CombinedModelSelector({
|
export function CombinedModelSelector({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -28,48 +509,102 @@ export function CombinedModelSelector({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
onSelect,
|
onSelect,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
canSelectProvider = () => true,
|
||||||
|
favoriteKeys = new Set<string>(),
|
||||||
|
onToggleFavorite,
|
||||||
|
renderTrigger,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||||
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
||||||
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||||
|
const providers = Array.from(allProviderModels.keys());
|
||||||
|
if (providers.length !== 1) return null;
|
||||||
|
const providerId = providers[0]!;
|
||||||
|
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||||
|
return { kind: "provider", providerId, providerLabel: label };
|
||||||
|
}, [allProviderModels, providerDefinitions]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
if (open) {
|
if (open) {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
onOpen?.();
|
||||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
|
||||||
setView({ provider: selectedProvider });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setView("groups");
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allProviderModels, selectedProvider],
|
[onOpen, onClose, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView("groups");
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect],
|
[onSelect, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
|
const selectedProviderLabel = useMemo(
|
||||||
|
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
||||||
|
[providerDefinitions, selectedProvider],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? "Loading..." : "Auto";
|
if (!models) {
|
||||||
const model = models.find((m) => m.id === selectedModel);
|
return isLoading ? "Loading..." : "Select model";
|
||||||
return model?.label ?? "Auto";
|
}
|
||||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
|
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
|
const desktopFixedHeight = useMemo(() => {
|
||||||
|
if (view.kind !== "provider") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const models = allProviderModels.get(view.providerId);
|
||||||
|
const modelCount = models?.length ?? 0;
|
||||||
|
return Math.min(80 + modelCount * 40, 400);
|
||||||
|
}, [allProviderModels, view]);
|
||||||
|
|
||||||
|
const triggerLabel = useMemo(() => {
|
||||||
|
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||||
|
return selectedModelLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
||||||
|
}, [selectedModelLabel, selectedProviderLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (platformIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsContentReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
setIsContentReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [isOpen, platformIsWeb]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -83,14 +618,26 @@ export function CombinedModelSelector({
|
|||||||
hovered && styles.triggerHovered,
|
hovered && styles.triggerHovered,
|
||||||
(pressed || isOpen) && styles.triggerPressed,
|
(pressed || isOpen) && styles.triggerPressed,
|
||||||
disabled && styles.triggerDisabled,
|
disabled && styles.triggerDisabled,
|
||||||
|
renderTrigger ? styles.customTriggerWrapper : null,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{renderTrigger ? (
|
||||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
renderTrigger({
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
selectedModelLabel: triggerLabel,
|
||||||
|
onPress: () => handleOpenChange(!isOpen),
|
||||||
|
disabled,
|
||||||
|
isOpen,
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||||
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={[]}
|
options={[]}
|
||||||
@@ -98,184 +645,62 @@ export function CombinedModelSelector({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
|
stackBehavior="push"
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
title="Select model"
|
title="Select model"
|
||||||
|
stickyHeader={
|
||||||
|
view.kind === "provider" ? (
|
||||||
|
<View style={styles.level2Header}>
|
||||||
|
{!singleProviderView ? (
|
||||||
|
<ProviderBackButton
|
||||||
|
providerId={view.providerId}
|
||||||
|
providerLabel={view.providerLabel}
|
||||||
|
onBack={() => {
|
||||||
|
setView({ kind: "all" });
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ProviderSearchInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoFocus={platformIsWeb}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{view === "groups" ? (
|
{isContentReady ? (
|
||||||
<GroupsView
|
<SelectorContent
|
||||||
|
view={view}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onDrillDown={(provider) => {
|
canSelectProvider={canSelectProvider}
|
||||||
setView({ provider });
|
onToggleFavorite={onToggleFavorite}
|
||||||
setSearchQuery("");
|
onDrillDown={(providerId, providerLabel) => {
|
||||||
|
setView({ kind: "provider", providerId, providerLabel });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DrillDownModelView
|
<View style={styles.sheetLoadingState}>
|
||||||
provider={view.provider}
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
providerDefinitions={providerDefinitions}
|
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
||||||
models={allProviderModels.get(view.provider) ?? []}
|
</View>
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onBack={() => {
|
|
||||||
setView("groups");
|
|
||||||
setSearchQuery("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupsView({
|
|
||||||
providerDefinitions,
|
|
||||||
allProviderModels,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
onSelect,
|
|
||||||
onDrillDown,
|
|
||||||
}: {
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
selectedProvider: string;
|
|
||||||
selectedModel: string;
|
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
|
||||||
onDrillDown: (provider: string) => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{providerDefinitions.map((def, index) => {
|
|
||||||
const models = allProviderModels.get(def.id) ?? [];
|
|
||||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
|
|
||||||
const ProvIcon = getProviderIcon(def.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={def.id}>
|
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
|
||||||
|
|
||||||
{isInline ? (
|
|
||||||
<>
|
|
||||||
<View style={styles.sectionHeading}>
|
|
||||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
|
||||||
</View>
|
|
||||||
{models.map((model) => (
|
|
||||||
<ComboboxItem
|
|
||||||
key={model.id}
|
|
||||||
label={model.label}
|
|
||||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
|
||||||
onPress={() => onSelect(def.id, model.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onDrillDown(def.id)}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.drillDownRow,
|
|
||||||
hovered && styles.drillDownRowHovered,
|
|
||||||
pressed && styles.drillDownRowPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
|
||||||
<View style={styles.drillDownTrailing}>
|
|
||||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
|
||||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrillDownModelView({
|
|
||||||
provider,
|
|
||||||
providerDefinitions,
|
|
||||||
models,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
onSelect,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
provider: string;
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
models: AgentModelDefinition[];
|
|
||||||
selectedProvider: string;
|
|
||||||
selectedModel: string;
|
|
||||||
searchQuery: string;
|
|
||||||
onSearchChange: (query: string) => void;
|
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const ProvIcon = getProviderIcon(provider);
|
|
||||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
|
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return models;
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
return models.filter(
|
|
||||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [models, searchQuery]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<Pressable
|
|
||||||
onPress={onBack}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.backButton,
|
|
||||||
hovered && styles.backButtonHovered,
|
|
||||||
pressed && styles.backButtonPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<SearchInput
|
|
||||||
placeholder="Search models..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChangeText={onSearchChange}
|
|
||||||
autoFocus={Platform.OS === "web"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{filteredModels.map((model) => (
|
|
||||||
<ComboboxItem
|
|
||||||
key={model.id}
|
|
||||||
label={model.label}
|
|
||||||
description={model.description}
|
|
||||||
selected={model.id === selectedModel && provider === selectedProvider}
|
|
||||||
onPress={() => onSelect(provider, model.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{filteredModels.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: {
|
trigger: {
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -300,17 +725,28 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
customTriggerWrapper: {
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
height: "auto",
|
||||||
|
},
|
||||||
|
favoritesContainer: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
separator: {
|
separator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
marginVertical: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
sectionHeadingText: {
|
sectionHeadingText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -324,6 +760,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
drillDownRowHovered: {
|
drillDownRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
@@ -333,8 +770,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
drillDownText: {
|
drillDownText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
drillDownTrailing: {
|
drillDownTrailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -345,6 +782,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
level2Header: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -353,9 +795,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
backButtonHovered: {
|
backButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
backButtonPressed: {
|
backButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -367,9 +810,46 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
favoriteButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
favoriteButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
favoriteButtonPressed: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
sheetLoadingState: {
|
||||||
|
minHeight: 160,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sheetLoadingText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
providerSearchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
|
},
|
||||||
|
providerSearchInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
|
|
||||||
|
export type SelectorModelRow = FavoriteModelRow;
|
||||||
|
|
||||||
|
export function resolveProviderLabel(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
providerId: string,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||||
|
return modelLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelRows(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||||
|
): SelectorModelRow[] {
|
||||||
|
const providerLabelMap = new Map(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition.label]),
|
||||||
|
);
|
||||||
|
const rows: SelectorModelRow[] = [];
|
||||||
|
|
||||||
|
for (const definition of providerDefinitions) {
|
||||||
|
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
||||||
|
for (const model of allProviderModels.get(definition.id) ?? []) {
|
||||||
|
rows.push({
|
||||||
|
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||||
|
provider: definition.id,
|
||||||
|
providerLabel,
|
||||||
|
modelId: model.id,
|
||||||
|
modelLabel: model.label,
|
||||||
|
description: model.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||||
|
value.toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Plus, Settings } from "lucide-react-native";
|
import { Plus, Settings } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
return `${agent.serverId}:${agent.id}`;
|
return `${agent.serverId}:${agent.id}`;
|
||||||
@@ -90,7 +91,7 @@ export function CommandCenter() {
|
|||||||
}
|
}
|
||||||
}, [activeIndex, open]);
|
}, [activeIndex, open]);
|
||||||
|
|
||||||
if (Platform.OS !== "web" || !open) return null;
|
if (isNative || !open) return null;
|
||||||
|
|
||||||
const actionItems = items.filter((item) => item.kind === "action");
|
const actionItems = items.filter((item) => item.kind === "action");
|
||||||
const agentItems = items.filter((item) => item.kind === "agent");
|
const agentItems = items.filter((item) => item.kind === "agent");
|
||||||
@@ -268,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
shadowColor: "#000",
|
...theme.shadow.lg,
|
||||||
shadowOpacity: 0.4,
|
|
||||||
shadowRadius: 24,
|
|
||||||
shadowOffset: { width: 0, height: 12 },
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import Svg, { Circle } from "react-native-svg";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
type ContextWindowMeterProps = {
|
||||||
|
maxTokens: number;
|
||||||
|
usedTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SVG_SIZE = 20;
|
||||||
|
const CENTER = SVG_SIZE / 2;
|
||||||
|
const RADIUS = 7;
|
||||||
|
const STROKE_WIDTH = 2.25;
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
|
||||||
|
function isValidMaxTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsedTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||||
|
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (usedTokens / maxTokens) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercentage(value: number): number {
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${Math.round(value / 1_000_000)}m`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${Math.round(value / 1_000)}k`;
|
||||||
|
}
|
||||||
|
return Math.round(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterColors(
|
||||||
|
percentage: number,
|
||||||
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
|
): { progress: string; track: string } {
|
||||||
|
const track = theme.colors.surface3;
|
||||||
|
if (percentage > 90) {
|
||||||
|
return { progress: theme.colors.destructive, track };
|
||||||
|
}
|
||||||
|
if (percentage >= 70) {
|
||||||
|
return { progress: theme.colors.palette.amber[500], track };
|
||||||
|
}
|
||||||
|
return { progress: theme.colors.foregroundMuted, track };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||||
|
|
||||||
|
if (percentage === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedPercentage = clampPercentage(percentage);
|
||||||
|
const roundedPercentage = Math.round(percentage);
|
||||||
|
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||||
|
const colors = getMeterColors(clampedPercentage, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||||
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
|
<Pressable
|
||||||
|
style={styles.container}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||||
|
>
|
||||||
|
<Svg
|
||||||
|
width={SVG_SIZE}
|
||||||
|
height={SVG_SIZE}
|
||||||
|
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||||
|
style={styles.svg}
|
||||||
|
accessibilityElementsHidden
|
||||||
|
importantForAccessibility="no-hide-descendants"
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.track}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.progress}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<View style={styles.tooltipContent}>
|
||||||
|
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||||
|
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.tooltipDetail}
|
||||||
|
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
svg: {
|
||||||
|
transform: [{ rotate: "-90deg" }],
|
||||||
|
},
|
||||||
|
tooltipContent: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tooltipTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
tooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
tooltipDetail: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: theme.fontSize.xs * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code-style titlebar drag region for Electron.
|
||||||
|
*
|
||||||
|
* Copied from VS Code at commit daa0a70:
|
||||||
|
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
|
||||||
|
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
|
||||||
|
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
|
||||||
|
*
|
||||||
|
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
|
||||||
|
* no state, no event listeners. Interactive elements get no-drag from their own
|
||||||
|
* CSS (global backstop in index.html). The drag region never re-renders.
|
||||||
|
*
|
||||||
|
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
|
||||||
|
* On macOS, Electron handles edge resize natively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
|
||||||
|
* Place as FIRST child of any positioned container that should be draggable.
|
||||||
|
*/
|
||||||
|
export function TitlebarDragRegion() {
|
||||||
|
if (isNative || !getIsElectronRuntime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
display: "block",
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "drag",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "no-drag",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
diffLines: DiffLine[];
|
diffLines: DiffLine[];
|
||||||
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
shadowColor: "#000",
|
...theme.shadow.md,
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
},
|
||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||||
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
||||||
|
|
||||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||||
|
|
||||||
@@ -133,8 +133,11 @@ export function DraggableList<T>({
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
const items = dragItems ?? data;
|
const items = dragItems ?? data;
|
||||||
|
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||||
|
enabled: showCustomScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -177,7 +180,6 @@ export function DraggableList<T>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
|
||||||
const wrapperStyle = [
|
const wrapperStyle = [
|
||||||
{ position: "relative" as const },
|
{ position: "relative" as const },
|
||||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||||
@@ -193,12 +195,10 @@ export function DraggableList<T>({
|
|||||||
style={style}
|
style={style}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
onScroll={scrollbar.onScroll}
|
||||||
}
|
scrollEventThrottle={16}
|
||||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
|
||||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
|
||||||
>
|
>
|
||||||
{ListHeaderComponent}
|
{ListHeaderComponent}
|
||||||
{items.length === 0 && ListEmptyComponent}
|
{items.length === 0 && ListEmptyComponent}
|
||||||
@@ -259,13 +259,7 @@ export function DraggableList<T>({
|
|||||||
{ListFooterComponent}
|
{ListFooterComponent}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={showCustomScrollbar}
|
|
||||||
metrics={scrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
useWindowDimensions,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
@@ -13,10 +19,13 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
@@ -39,7 +48,7 @@ export function ExplorerSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -79,6 +88,7 @@ export function ExplorerSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
|
||||||
@@ -99,6 +109,11 @@ export function ExplorerSidebar({
|
|||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
closeToAgent();
|
||||||
|
}, [closeToAgent, gestureAnimatingRef]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
@@ -173,7 +188,7 @@ export function ExplorerSidebar({
|
|||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)("swipe-close-gesture");
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -188,7 +203,7 @@ export function ExplorerSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleClose,
|
handleCloseFromGesture,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
@@ -237,7 +252,7 @@ export function ExplorerSidebar({
|
|||||||
|
|
||||||
// Mobile: full-screen overlay with gesture.
|
// Mobile: full-screen overlay with gesture.
|
||||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
|
|
||||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||||
@@ -249,18 +264,17 @@ export function ExplorerSidebar({
|
|||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
||||||
<Pressable
|
|
||||||
style={styles.backdropPressable}
|
|
||||||
onPress={() => handleClose("backdrop-press")}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.mobileSidebar,
|
explorerStaticStyles.mobileSidebar,
|
||||||
{ width: windowWidth, paddingTop: insets.top },
|
{
|
||||||
|
width: windowWidth,
|
||||||
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
},
|
||||||
sidebarAnimatedStyle,
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
]}
|
]}
|
||||||
@@ -289,25 +303,27 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
<Animated.View
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
>
|
||||||
<View
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
/>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
</GestureDetector>
|
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
activeTab={explorerTab}
|
activeTab={explorerTab}
|
||||||
onTabPress={handleTabPress}
|
onTabPress={handleTabPress}
|
||||||
onClose={() => handleClose("desktop-close-button")}
|
onClose={() => handleClose("desktop-close-button")}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -336,12 +352,14 @@ function SidebarContent({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const padding = useWindowControlsPadding("explorerSidebar");
|
||||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
{/* Header with tabs and close button */}
|
{/* Header with tabs and close button */}
|
||||||
<View style={styles.header} testID="explorer-header">
|
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||||
|
<TitlebarDragRegion />
|
||||||
<View style={styles.tabsContainer}>
|
<View style={styles.tabsContainer}>
|
||||||
{isGit && (
|
{isGit && (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -396,24 +414,28 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||||
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||||
|
// tries to patch the native node that Reanimated also manages.
|
||||||
|
const explorerStaticStyles = RNStyleSheet.create({
|
||||||
backdrop: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...RNStyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute",
|
position: "absolute" as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
overflow: "hidden" as const,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative",
|
position: "relative" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
desktopSidebarBorder: {
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: theme.colors.border,
|
borderLeftColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
@@ -432,6 +454,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
|
position: "relative",
|
||||||
height: HEADER_INNER_HEIGHT,
|
height: HEADER_INNER_HEIGHT,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -453,7 +476,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { View, Text, Platform } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Upload } from "lucide-react-native";
|
import { Upload } from "lucide-react-native";
|
||||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||||
import type { ImageAttachment } from "./message-input";
|
import type { ImageAttachment } from "./message-input";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface FileDropZoneProps {
|
interface FileDropZoneProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeScrollEvent,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -26,18 +23,17 @@ import Animated, {
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
Image as ImageIcon,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -54,10 +50,8 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
|
|||||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import {
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
WebDesktopScrollbarOverlay,
|
import { isWeb } from "@/constants/platform";
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "@/components/web-desktop-scrollbar";
|
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
{ value: "name", label: "Name" },
|
{ value: "name", label: "Name" },
|
||||||
@@ -65,7 +59,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|||||||
{ value: "size", label: "Size" },
|
{ value: "size", label: "Size" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const INDENT_PER_LEVEL = 12;
|
const INDENT_PER_LEVEL = 16;
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
@@ -96,8 +90,8 @@ export function FileExplorerPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
@@ -124,18 +118,22 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
useFileExplorerActions({
|
||||||
requestDirectoryListing,
|
serverId,
|
||||||
requestFileDownloadToken,
|
workspaceId,
|
||||||
selectExplorerEntry,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
} = useFileExplorerActions({
|
});
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
|
||||||
});
|
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||||
|
const expandedPathsArray = usePanelStore((state) =>
|
||||||
|
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||||
|
);
|
||||||
|
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
||||||
|
const expandedPaths = useMemo(
|
||||||
|
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||||
|
[expandedPathsArray],
|
||||||
|
);
|
||||||
|
|
||||||
const directories = explorerState?.directories ?? new Map();
|
const directories = explorerState?.directories ?? new Map();
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
@@ -151,16 +149,16 @@ export function FileExplorerPane({
|
|||||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
setExpandedPaths(new Set(["."]));
|
}, [workspaceStateKey]);
|
||||||
}, [actionsWorkspaceStateKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -174,23 +172,33 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
const persistedPaths =
|
||||||
|
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||||
|
if (persistedPaths) {
|
||||||
|
for (const path of persistedPaths) {
|
||||||
|
if (path !== ".") {
|
||||||
|
void requestDirectoryListing(path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
if (!selectedEntryPath || !workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
const parentDir = getParentDirectory(selectedEntryPath);
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
const ancestors = getAncestorDirectories(parentDir);
|
||||||
|
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||||
setExpandedPaths((prev) => {
|
if (newPaths.length === 0) {
|
||||||
const next = new Set(prev);
|
return;
|
||||||
ancestors.forEach((path) => next.add(path));
|
}
|
||||||
return next;
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||||
});
|
newPaths.forEach((path) => {
|
||||||
|
|
||||||
ancestors.forEach((path) => {
|
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -198,34 +206,43 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
}, [
|
||||||
|
directories,
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
requestDirectoryListing,
|
||||||
|
selectedEntryPath,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
const nextExpanded = !isExpanded;
|
if (isExpanded) {
|
||||||
setExpandedPaths((prev) => {
|
setExpandedPathsForWorkspace(
|
||||||
const next = new Set(prev);
|
workspaceStateKey,
|
||||||
if (isExpanded) {
|
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||||
next.delete(entry.path);
|
);
|
||||||
} else {
|
} else {
|
||||||
next.add(entry.path);
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||||
|
if (!directories.has(entry.path)) {
|
||||||
|
void requestDirectoryListing(entry.path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextExpanded && !directories.has(entry.path)) {
|
|
||||||
void requestDirectoryListing(entry.path, {
|
|
||||||
recordHistory: false,
|
|
||||||
setCurrentPath: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
[
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
directories,
|
||||||
|
requestDirectoryListing,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(
|
const handleOpenFile = useCallback(
|
||||||
@@ -384,7 +401,6 @@ export function FileExplorerPane({
|
|||||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||||
const entry = item.entry;
|
const entry = item.entry;
|
||||||
const depth = item.depth;
|
const depth = item.depth;
|
||||||
const displayKind = getEntryDisplayKind(entry);
|
|
||||||
const isDirectory = entry.kind === "directory";
|
const isDirectory = entry.kind === "directory";
|
||||||
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
||||||
const isSelected = selectedEntryPath === entry.path;
|
const isSelected = selectedEntryPath === entry.path;
|
||||||
@@ -399,16 +415,30 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed || isSelected) && styles.entryRowActive,
|
(hovered || pressed || isSelected) && styles.entryRowActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{depth > 0 &&
|
||||||
|
Array.from({ length: depth }, (_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.indentGuide,
|
||||||
|
{
|
||||||
|
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{loading ? (
|
{isDirectory ? (
|
||||||
<ActivityIndicator size="small" />
|
loading ? (
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
|
||||||
|
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
renderEntryIcon(isDirectory ? "directory" : displayKind, {
|
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
|
||||||
foreground: theme.colors.foregroundMuted,
|
|
||||||
primary: theme.colors.primary,
|
|
||||||
directoryOpen: isExpanded,
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.entryName} numberOfLines={1}>
|
<Text style={styles.entryName} numberOfLines={1}>
|
||||||
@@ -490,24 +520,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
||||||
|
|
||||||
const handleTreeListScroll = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
treeScrollbarMetrics.onScroll(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTreeListLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => {
|
|
||||||
if (showDesktopWebScrollbar) {
|
|
||||||
treeScrollbarMetrics.onLayout(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -552,27 +564,31 @@ export function FileExplorerPane({
|
|||||||
) : (
|
) : (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<View style={styles.paneHeader} testID="files-pane-header">
|
||||||
<View style={styles.paneHeaderLeft} />
|
<Pressable
|
||||||
<View style={styles.paneHeaderRight}>
|
onPress={handleSortCycle}
|
||||||
<Pressable
|
style={({ hovered, pressed }) => [
|
||||||
onPress={handleRefresh}
|
styles.sortTrigger,
|
||||||
disabled={isRefreshFetching}
|
(hovered || pressed) && styles.sortTriggerHovered,
|
||||||
hitSlop={8}
|
]}
|
||||||
style={({ hovered, pressed }) => [
|
>
|
||||||
styles.iconButton,
|
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||||
(hovered || pressed) && styles.iconButtonHovered,
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
]}
|
</Pressable>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
accessibilityLabel="Refresh files"
|
onPress={handleRefresh}
|
||||||
>
|
disabled={isRefreshFetching}
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
hitSlop={8}
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
style={({ hovered, pressed }) => [
|
||||||
</Animated.View>
|
styles.iconButton,
|
||||||
</Pressable>
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
]}
|
||||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
accessibilityRole="button"
|
||||||
</Pressable>
|
accessibilityLabel="Refresh files"
|
||||||
</View>
|
>
|
||||||
|
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={treeListRef}
|
ref={treeListRef}
|
||||||
@@ -582,126 +598,22 @@ export function FileExplorerPane({
|
|||||||
keyExtractor={(row) => row.entry.path}
|
keyExtractor={(row) => row.entry.path}
|
||||||
testID="file-explorer-tree-scroll"
|
testID="file-explorer-tree-scroll"
|
||||||
contentContainerStyle={styles.entriesContent}
|
contentContainerStyle={styles.entriesContent}
|
||||||
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
|
||||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
initialNumToRender={24}
|
initialNumToRender={24}
|
||||||
maxToRenderPerBatch={40}
|
maxToRenderPerBatch={40}
|
||||||
windowSize={12}
|
windowSize={12}
|
||||||
/>
|
/>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={showDesktopWebScrollbar}
|
|
||||||
metrics={treeScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
treeListRef.current?.scrollToOffset({
|
|
||||||
offset: nextOffset,
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
|
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set([
|
|
||||||
"txt",
|
|
||||||
"md",
|
|
||||||
"markdown",
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"json",
|
|
||||||
"yml",
|
|
||||||
"yaml",
|
|
||||||
"toml",
|
|
||||||
"py",
|
|
||||||
"rb",
|
|
||||||
"go",
|
|
||||||
"rs",
|
|
||||||
"java",
|
|
||||||
"kt",
|
|
||||||
"c",
|
|
||||||
"cpp",
|
|
||||||
"cc",
|
|
||||||
"h",
|
|
||||||
"hpp",
|
|
||||||
"cs",
|
|
||||||
"swift",
|
|
||||||
"php",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"less",
|
|
||||||
"xml",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function renderEntryIcon(
|
|
||||||
kind: EntryDisplayKind,
|
|
||||||
colors: { foreground: string; primary: string; directoryOpen?: boolean },
|
|
||||||
) {
|
|
||||||
const color = colors.foreground;
|
|
||||||
switch (kind) {
|
|
||||||
case "directory":
|
|
||||||
return colors.directoryOpen ? (
|
|
||||||
<FolderOpen size={18} color={colors.primary} />
|
|
||||||
) : (
|
|
||||||
<Folder size={18} color={colors.primary} />
|
|
||||||
);
|
|
||||||
case "image":
|
|
||||||
return <ImageIcon size={18} color={color} />;
|
|
||||||
case "text":
|
|
||||||
return <FileText size={18} color={color} />;
|
|
||||||
default:
|
|
||||||
return <File size={18} color={color} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
|
|
||||||
if (entry.kind === "directory") {
|
|
||||||
return "directory";
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = getExtension(entry.name);
|
|
||||||
if (extension === null) {
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TEXT_EXTENSIONS.has(extension)) {
|
|
||||||
return "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExtension(name: string): string | null {
|
|
||||||
const index = name.lastIndexOf(".");
|
|
||||||
if (index === -1 || index === name.length - 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return name.slice(index + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||||
const sorted = [...entries];
|
const sorted = [...entries];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@@ -842,50 +754,38 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
paneHeader: {
|
paneHeader: {
|
||||||
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
paddingRight: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
},
|
||||||
paneHeaderLeft: {
|
sortTrigger: {
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
paneHeaderRight: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
previewHeaderRight: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
sortButton: {
|
|
||||||
height: 28,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.md,
|
marginLeft: theme.spacing[3] - theme.spacing[1],
|
||||||
borderWidth: theme.borderWidth[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
borderColor: theme.colors.border,
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
},
|
},
|
||||||
sortButtonText: {
|
sortTriggerHovered: {
|
||||||
color: theme.colors.foregroundMuted,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
sortTriggerText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
treeList: {
|
treeList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
entriesContent: {
|
entriesContent: {
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
centerState: {
|
centerState: {
|
||||||
@@ -936,8 +836,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
indentGuide: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 1,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
entryInfo: {
|
entryInfo: {
|
||||||
@@ -947,6 +855,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
chevron: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chevronExpanded: {
|
||||||
|
transform: [{ rotate: "90deg" }],
|
||||||
|
},
|
||||||
entryIcon: {
|
entryIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -6,18 +6,12 @@ import {
|
|||||||
ScrollView as RNScrollView,
|
ScrollView as RNScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeScrollEvent,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import {
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
WebDesktopScrollbarOverlay,
|
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "@/components/web-desktop-scrollbar";
|
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -26,6 +20,7 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -119,13 +114,14 @@ function FilePreviewBody({
|
|||||||
filePath,
|
filePath,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colors.surface0 === "#18181c";
|
const isDark = theme.colorScheme === "dark";
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
|
||||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
});
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text") {
|
if (!preview || preview.kind !== "text") {
|
||||||
@@ -140,24 +136,6 @@ function FilePreviewBody({
|
|||||||
return lineNumberGutterWidth(highlightedLines.length);
|
return lineNumberGutterWidth(highlightedLines.length);
|
||||||
}, [highlightedLines]);
|
}, [highlightedLines]);
|
||||||
|
|
||||||
const handlePreviewScroll = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
if (enablePreviewDesktopScrollbar) {
|
|
||||||
previewScrollbarMetrics.onScroll(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreviewLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => {
|
|
||||||
if (enablePreviewDesktopScrollbar) {
|
|
||||||
previewScrollbarMetrics.onLayout(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -197,13 +175,11 @@ function FilePreviewBody({
|
|||||||
<RNScrollView
|
<RNScrollView
|
||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||||
@@ -218,13 +194,7 @@ function FilePreviewBody({
|
|||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
)}
|
)}
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={enablePreviewDesktopScrollbar}
|
|
||||||
metrics={previewScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -236,13 +206,11 @@ function FilePreviewBody({
|
|||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
contentContainerStyle={styles.previewImageScrollContent}
|
contentContainerStyle={styles.previewImageScrollContent}
|
||||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
onLayout={scrollbar.onLayout}
|
||||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
onScroll={scrollbar.onScroll}
|
||||||
onContentSizeChange={
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
scrollEventThrottle={16}
|
||||||
}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
||||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
@@ -252,13 +220,7 @@ function FilePreviewBody({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
<WebDesktopScrollbarOverlay
|
{scrollbar.overlay}
|
||||||
enabled={enablePreviewDesktopScrollbar}
|
|
||||||
metrics={previewScrollbarMetrics}
|
|
||||||
onScrollToOffset={(nextOffset) => {
|
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,8 +242,8 @@ export function FilePane({
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
baseRefAvailable: true,
|
baseRefAvailable: true,
|
||||||
baseRefLabel: "main",
|
baseRefLabel: "main",
|
||||||
aheadCount: 0,
|
aheadCount: 0,
|
||||||
|
behindBaseCount: 0,
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
shouldPromoteArchive: false,
|
shouldPromoteArchive: false,
|
||||||
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -56,89 +62,102 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("git-actions-policy", () => {
|
describe("git-actions-policy", () => {
|
||||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
it("shows only remote sync actions on the base branch", () => {
|
||||||
const noPrActions = buildGitActions(createInput());
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
const withPrActions = buildGitActions(
|
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
hasPullRequest: true,
|
|
||||||
pullRequestUrl: "https://example.com/pr/123",
|
|
||||||
aheadCount: 3,
|
|
||||||
aheadOfOrigin: 2,
|
|
||||||
shipDefault: "pr",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(noPrActions.primary).toBeNull();
|
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
|
||||||
expect(withPrActions.primary?.id).toBe("push");
|
|
||||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
"merge-from-base",
|
|
||||||
"push",
|
|
||||||
]);
|
|
||||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
"merge-from-base",
|
|
||||||
"push",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables hidden-before actions with explanations instead", () => {
|
it("prioritizes pull when the branch is behind origin", () => {
|
||||||
const actions = buildGitActions(createInput());
|
|
||||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
|
||||||
|
|
||||||
expect(actionById.get("push")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No remote configured",
|
|
||||||
});
|
|
||||||
expect(actionById.get("pr")).toMatchObject({
|
|
||||||
label: "Create PR",
|
|
||||||
disabled: true,
|
|
||||||
description: "Branch has no commits ahead of main",
|
|
||||||
});
|
|
||||||
expect(actionById.get("merge-branch")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No commits to merge into main",
|
|
||||||
});
|
|
||||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No remote configured",
|
|
||||||
});
|
|
||||||
expect(actionById.has("archive-worktree")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the current primary action visible in the menu", () => {
|
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
|
behindOfOrigin: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
aheadOfOrigin: 1,
|
||||||
|
behindOfOrigin: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const pushAction = actions.secondary.find((action) => action.id === "push");
|
||||||
|
|
||||||
|
expect(pushAction).toMatchObject({
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage:
|
||||||
|
"Push isn't available yet because there are newer changes to bring in first",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows update-from-base only on feature branches that are behind the base branch", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
behindBaseCount: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||||
|
|
||||||
|
expect(updateAction).toMatchObject({
|
||||||
|
label: "Update from main",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a clear sentence when pull is unavailable", () => {
|
||||||
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
|
const pullAction = actions.secondary.find((action) => action.id === "pull");
|
||||||
|
|
||||||
|
expect(pullAction).toMatchObject({
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: "Pull isn't available because this branch is already up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps update-from-base off the base branch entirely", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
behindOfOrigin: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps feature branch actions available off the base branch", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
behindBaseCount: 1,
|
||||||
hasPullRequest: true,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actions.primary?.id).toBe("pr");
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"pull",
|
||||||
|
"push",
|
||||||
|
"merge-from-base",
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables sync on the base branch when already up to date", () => {
|
|
||||||
const actions = buildGitActions(
|
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
|
||||||
|
|
||||||
expect(syncAction).toMatchObject({
|
|
||||||
label: "Sync",
|
|
||||||
disabled: true,
|
|
||||||
description: "Already up to date",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only shows archive worktree for paseo worktrees", () => {
|
it("only shows archive worktree for paseo worktrees", () => {
|
||||||
const hidden = buildGitActions(createInput());
|
const hidden = buildGitActions(createInput());
|
||||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
|||||||
|
|
||||||
export type GitActionId =
|
export type GitActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
|
| "pull"
|
||||||
| "push"
|
| "push"
|
||||||
| "pr"
|
| "pr"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
@@ -17,7 +18,7 @@ export interface GitAction {
|
|||||||
successLabel: string;
|
successLabel: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
status: ActionStatus;
|
status: ActionStatus;
|
||||||
description?: string;
|
unavailableMessage?: string;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
handler: () => void;
|
handler: () => void;
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
|
|||||||
baseRefAvailable: boolean;
|
baseRefAvailable: boolean;
|
||||||
baseRefLabel: string;
|
baseRefLabel: string;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
|
behindBaseCount: number;
|
||||||
aheadOfOrigin: number;
|
aheadOfOrigin: number;
|
||||||
behindOfOrigin: number;
|
behindOfOrigin: number;
|
||||||
shouldPromoteArchive: boolean;
|
shouldPromoteArchive: boolean;
|
||||||
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
|
||||||
|
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
|
||||||
|
|
||||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||||
if (!input.isGit) {
|
if (!input.isGit) {
|
||||||
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime.commit.handler,
|
handler: input.runtime.commit.handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allActions.set("pull", {
|
||||||
|
id: "pull",
|
||||||
|
label: "Pull",
|
||||||
|
pendingLabel: "Pulling...",
|
||||||
|
successLabel: "Pulled",
|
||||||
|
disabled: input.runtime.pull.disabled,
|
||||||
|
status: input.runtime.pull.status,
|
||||||
|
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
|
||||||
|
icon: input.runtime.pull.icon,
|
||||||
|
handler: input.runtime.pull.handler,
|
||||||
|
});
|
||||||
|
|
||||||
allActions.set("push", {
|
allActions.set("push", {
|
||||||
id: "push",
|
id: "push",
|
||||||
label: "Push",
|
label: "Push",
|
||||||
pendingLabel: "Pushing...",
|
pendingLabel: "Pushing...",
|
||||||
successLabel: "Pushed",
|
successLabel: "Pushed",
|
||||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
disabled: input.runtime.push.disabled,
|
||||||
status: input.runtime.push.status,
|
status: input.runtime.push.status,
|
||||||
description: input.hasRemote ? undefined : "No remote configured",
|
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
|
||||||
icon: input.runtime.push.icon,
|
icon: input.runtime.push.icon,
|
||||||
handler: input.runtime.push.handler,
|
handler: input.runtime.push.handler,
|
||||||
});
|
});
|
||||||
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: `Merge into ${input.baseRefLabel}`,
|
label: `Merge into ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Merging...",
|
pendingLabel: "Merging...",
|
||||||
successLabel: "Merged",
|
successLabel: "Merged",
|
||||||
disabled:
|
disabled: input.runtime["merge-branch"].disabled,
|
||||||
input.runtime["merge-branch"].disabled ||
|
|
||||||
!input.baseRefAvailable ||
|
|
||||||
input.hasUncommittedChanges ||
|
|
||||||
input.aheadCount === 0,
|
|
||||||
status: input.runtime["merge-branch"].status,
|
status: input.runtime["merge-branch"].status,
|
||||||
description: getMergeBranchDescription(input),
|
unavailableMessage: input.runtime["merge-branch"].disabled
|
||||||
|
? undefined
|
||||||
|
: getMergeBranchUnavailableMessage(input),
|
||||||
icon: input.runtime["merge-branch"].icon,
|
icon: input.runtime["merge-branch"].icon,
|
||||||
handler: input.runtime["merge-branch"].handler,
|
handler: input.runtime["merge-branch"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
label: `Update from ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Updating...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
successLabel: "Updated",
|
||||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
disabled: input.runtime["merge-from-base"].disabled,
|
||||||
status: input.runtime["merge-from-base"].status,
|
status: input.runtime["merge-from-base"].status,
|
||||||
description: getMergeFromBaseDescription(input),
|
unavailableMessage: input.runtime["merge-from-base"].disabled
|
||||||
|
? undefined
|
||||||
|
: getMergeFromBaseUnavailableMessage(input),
|
||||||
icon: input.runtime["merge-from-base"].icon,
|
icon: input.runtime["merge-from-base"].icon,
|
||||||
handler: input.runtime["merge-from-base"].handler,
|
handler: input.runtime["merge-from-base"].handler,
|
||||||
});
|
});
|
||||||
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: "Archive worktree",
|
label: "Archive worktree",
|
||||||
pendingLabel: "Archiving...",
|
pendingLabel: "Archiving...",
|
||||||
successLabel: "Archived",
|
successLabel: "Archived",
|
||||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
disabled: input.runtime["archive-worktree"].disabled,
|
||||||
status: input.runtime["archive-worktree"].status,
|
status: input.runtime["archive-worktree"].status,
|
||||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
unavailableMessage:
|
||||||
|
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||||
|
? undefined
|
||||||
|
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||||
icon: input.runtime["archive-worktree"].icon,
|
icon: input.runtime["archive-worktree"].icon,
|
||||||
handler: input.runtime["archive-worktree"].handler,
|
handler: input.runtime["archive-worktree"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
const primaryActionId = getPrimaryActionId(input);
|
||||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
||||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
|
||||||
|
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||||
|
if (!input.isOnBaseBranch) {
|
||||||
|
secondaryIds.push(...FEATURE_ACTION_IDS);
|
||||||
|
}
|
||||||
if (input.isPaseoOwnedWorktree) {
|
if (input.isPaseoOwnedWorktree) {
|
||||||
secondary.push(allActions.get("archive-worktree")!);
|
secondaryIds.push("archive-worktree");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { primary, secondary, menu: [] };
|
return {
|
||||||
|
primary,
|
||||||
|
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||||
|
menu: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||||
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "commit";
|
return "commit";
|
||||||
}
|
}
|
||||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
if (canPull(input)) {
|
||||||
|
return "pull";
|
||||||
|
}
|
||||||
|
if (canPush(input)) {
|
||||||
return "push";
|
return "push";
|
||||||
}
|
}
|
||||||
|
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||||
|
return "merge-from-base";
|
||||||
|
}
|
||||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return "pr";
|
return "pr";
|
||||||
}
|
}
|
||||||
if (
|
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||||
input.isOnBaseBranch &&
|
|
||||||
input.hasRemote &&
|
|
||||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
|
||||||
) {
|
|
||||||
return "merge-from-base";
|
|
||||||
}
|
|
||||||
if (input.aheadCount > 0) {
|
|
||||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "View PR",
|
label: "View PR",
|
||||||
pendingLabel: "View PR",
|
pendingLabel: "View PR",
|
||||||
successLabel: "View PR",
|
successLabel: "View PR",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
unavailableMessage:
|
||||||
|
input.runtime.pr.disabled || input.githubFeaturesEnabled
|
||||||
|
? undefined
|
||||||
|
: "View PR isn't available right now because GitHub isn't connected",
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
@@ -184,65 +212,100 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
pendingLabel: "Creating PR...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: getCreatePrDescription(input),
|
unavailableMessage: input.runtime.pr.disabled
|
||||||
|
? undefined
|
||||||
|
: getCreatePrUnavailableMessage(input),
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canPull(input: BuildGitActionsInput): boolean {
|
||||||
|
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPush(input: BuildGitActionsInput): boolean {
|
||||||
|
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canMergeBranch(input: BuildGitActionsInput): boolean {
|
||||||
|
return (
|
||||||
|
!input.isOnBaseBranch &&
|
||||||
|
input.baseRefAvailable &&
|
||||||
|
!input.hasUncommittedChanges &&
|
||||||
|
input.aheadCount > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
return (
|
||||||
return false;
|
!input.isOnBaseBranch &&
|
||||||
}
|
input.baseRefAvailable &&
|
||||||
if (!input.isOnBaseBranch) {
|
!input.hasUncommittedChanges &&
|
||||||
return true;
|
input.behindBaseCount > 0
|
||||||
}
|
);
|
||||||
if (!input.hasRemote) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return "Pull isn't available here because this branch is not connected to a remote yet";
|
||||||
|
}
|
||||||
|
if (input.hasUncommittedChanges) {
|
||||||
|
return "Pull isn't available while you have local changes so commit or stash them first";
|
||||||
|
}
|
||||||
|
if (input.behindOfOrigin === 0) {
|
||||||
|
return "Pull isn't available because this branch is already up to date";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return "Push isn't available here because this branch is not connected to a remote yet";
|
||||||
|
}
|
||||||
|
if (input.behindOfOrigin > 0) {
|
||||||
|
return "Push isn't available yet because there are newer changes to bring in first";
|
||||||
|
}
|
||||||
|
if (input.aheadOfOrigin === 0) {
|
||||||
|
return "Push isn't available because there is nothing new to send";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.githubFeaturesEnabled) {
|
if (!input.githubFeaturesEnabled) {
|
||||||
return "GitHub features unavailable";
|
return "Create PR isn't available right now because GitHub isn't connected";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
return "Create PR isn't available because this branch doesn't have any new commits yet";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Merge isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Requires clean working tree";
|
return "Merge isn't available while you have local changes so commit or stash them first";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return `No commits to merge into ${input.baseRefLabel}`;
|
return "Merge isn't available because this branch doesn't have anything new to merge yet";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Update isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Requires clean working tree";
|
return "Update isn't available while you have local changes so commit or stash them first";
|
||||||
}
|
}
|
||||||
if (!input.isOnBaseBranch) {
|
if (input.behindBaseCount === 0) {
|
||||||
return undefined;
|
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
|
||||||
}
|
|
||||||
if (!input.hasRemote) {
|
|
||||||
return "No remote configured";
|
|
||||||
}
|
|
||||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
|
||||||
return "Already up to date";
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user