mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
354 Commits
v0.1.18
...
android-v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 | ||
|
|
f1cf4ebe60 | ||
|
|
0a452fad04 | ||
|
|
eb618638d2 | ||
|
|
7b8a2fbcef | ||
|
|
928169a4f2 | ||
|
|
0af666e3ac | ||
|
|
d41808efe1 | ||
|
|
aec28c8b67 | ||
|
|
3b5daa1451 | ||
|
|
f6579dbeb4 | ||
|
|
ab40786882 | ||
|
|
5f065c2685 | ||
|
|
6b50ace2bd | ||
|
|
c46c03158e | ||
|
|
7395fc6fd1 | ||
|
|
c35d649f6d | ||
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb | ||
|
|
ec8c51a014 | ||
|
|
84d3aa1962 | ||
|
|
0bc693f157 | ||
|
|
5ae7a0c9c2 | ||
|
|
e6bc752b68 | ||
|
|
78fc1fe862 | ||
|
|
84500cdcae | ||
|
|
480af26b57 | ||
|
|
2003f308f3 | ||
|
|
a042fbbe43 | ||
|
|
ee5577c2da | ||
|
|
24945a9498 | ||
|
|
f6f689d570 | ||
|
|
7703625e76 | ||
|
|
17b81fb132 | ||
|
|
111576e2ca | ||
|
|
862cc43db7 | ||
|
|
48924626df | ||
|
|
72ea9b7a72 | ||
|
|
e0f9b33d23 | ||
|
|
c7fe944e73 | ||
|
|
584f5ce05e | ||
|
|
614c085310 | ||
|
|
866aeb8ad9 | ||
|
|
23aaecd99a | ||
|
|
77fb74c188 | ||
|
|
70fb5f5bfd | ||
|
|
26c07b671f | ||
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 | ||
|
|
1d3e551e7f | ||
|
|
79795b6f49 | ||
|
|
16da3300e3 | ||
|
|
69d8427bd9 | ||
|
|
d8ccbd5c32 | ||
|
|
be93f8b240 | ||
|
|
b09a731d85 | ||
|
|
554017b0b8 | ||
|
|
9f089be946 | ||
|
|
213f155c9c | ||
|
|
9e6b45e2f0 | ||
|
|
355e56db53 | ||
|
|
a79134ec9e | ||
|
|
91dde29146 | ||
|
|
586b48e150 | ||
|
|
aeefa22ddf | ||
|
|
9f3ef07322 | ||
|
|
e2cb67462d | ||
|
|
b60d253926 | ||
|
|
ee156adffb | ||
|
|
c99b78f5b0 | ||
|
|
4c6d21af4a | ||
|
|
327b315610 | ||
|
|
78849fa0bf | ||
|
|
e5014a5f57 | ||
|
|
5bf698ff84 | ||
|
|
eb5f011161 | ||
|
|
acfb933ee8 | ||
|
|
c37684b246 | ||
|
|
e2068e3d72 | ||
|
|
443eb16e67 | ||
|
|
240dc26013 | ||
|
|
6b07555a46 | ||
|
|
b69bd5271b | ||
|
|
cf4cae2c7d | ||
|
|
d51f18a2f7 | ||
|
|
006db65f08 | ||
|
|
f21221c1e1 | ||
|
|
9faa88e13b | ||
|
|
faf1eed0ab | ||
|
|
8fc37eac52 | ||
|
|
9e76d1c2d6 | ||
|
|
5609c89517 | ||
|
|
4e4a751921 | ||
|
|
6b4978b428 | ||
|
|
e1f4e6fafb | ||
|
|
f09b43eef0 | ||
|
|
2813f35eb1 | ||
|
|
90dfe36e3e | ||
|
|
7588c1791b | ||
|
|
bfa7f65c3d | ||
|
|
51bbebcdd5 | ||
|
|
7b4ca8394b | ||
|
|
438a9f6d48 | ||
|
|
9604b8d57b | ||
|
|
2a0b0b9109 | ||
|
|
f3338ee824 | ||
|
|
e73d40b260 | ||
|
|
89a25276a5 | ||
|
|
244eed8696 | ||
|
|
dce7316931 | ||
|
|
d69addaad2 | ||
|
|
845cf68d38 | ||
|
|
2b17aa1a1d | ||
|
|
d32c196fd5 | ||
|
|
a0266e29e3 | ||
|
|
752d29c146 | ||
|
|
36660b3cc1 | ||
|
|
bf355aaaf3 | ||
|
|
98d91fd696 | ||
|
|
8dfc866d40 | ||
|
|
15e9569157 | ||
|
|
483dd7cb6d | ||
|
|
6a0e48c10c | ||
|
|
7a4be5233c | ||
|
|
965704da20 | ||
|
|
f760255d50 | ||
|
|
f3acdedfb1 | ||
|
|
cc45c3772f | ||
|
|
a3e271a1e7 | ||
|
|
7b22fc5c3f | ||
|
|
a15b52efc8 | ||
|
|
7f11b93e0f | ||
|
|
02d74777b2 | ||
|
|
cf148ba3af | ||
|
|
19b6aaa2f3 | ||
|
|
97737be91c | ||
|
|
e3d7dabb87 | ||
|
|
a90a7f454c | ||
|
|
8a60dc30d6 | ||
|
|
06f8722f25 | ||
|
|
e3552f6365 | ||
|
|
7c6eb2ad74 | ||
|
|
2721ce331a | ||
|
|
ca787271b3 | ||
|
|
87948e956a | ||
|
|
1e5e0f625d | ||
|
|
6f7b3db4fa | ||
|
|
93b5cc530c | ||
|
|
785124eb9f | ||
|
|
2a1ef17107 | ||
|
|
2d5d0dcacd | ||
|
|
faa5aaab6f | ||
|
|
03e1915316 | ||
|
|
b339c5e61d | ||
|
|
0c44e7db80 | ||
|
|
870d95f35e | ||
|
|
cfb9784ea9 | ||
|
|
484edb1e1e | ||
|
|
356faa0563 | ||
|
|
7d76da3249 | ||
|
|
1d1c7058f1 | ||
|
|
6b51088f39 |
15
.github/workflows/android-apk-release.yml
vendored
15
.github/workflows/android-apk-release.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -16,7 +17,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
@@ -31,6 +32,18 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[2]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'app-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
363
.github/workflows/desktop-release.yml
vendored
363
.github/workflows/desktop-release.yml
vendored
@@ -5,25 +5,50 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- 'desktop-macos-v*'
|
||||
- 'desktop-linux-v*'
|
||||
- 'desktop-windows-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
platform:
|
||||
description: 'Optional desktop platform to build.'
|
||||
required: false
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- macos
|
||||
- linux
|
||||
- windows
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
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'))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
electron_arch: arm64
|
||||
- runner: macos-15-intel
|
||||
electron_arch: x64
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,83 +56,305 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
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'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target universal-apple-darwin
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- 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
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
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'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build workspace dependencies
|
||||
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||
|
||||
- name: Build web app for desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
10
.github/workflows/release-notes-sync.yml
vendored
10
.github/workflows/release-notes-sync.yml
vendored
@@ -19,6 +19,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
draft:
|
||||
description: "Create missing release as draft."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
@@ -43,6 +48,7 @@ jobs:
|
||||
REF: ${{ github.ref }}
|
||||
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_DRAFT: ${{ github.event.inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -64,4 +70,8 @@ jobs:
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
||||
args+=(--draft)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
|
||||
6
.github/workflows/server-ci.yml
vendored
6
.github/workflows/server-ci.yml
vendored
@@ -36,8 +36,14 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -46,6 +46,9 @@ test-results/
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
@@ -71,8 +74,12 @@ valknut-report.json/
|
||||
**/.paseo-provider-history/
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.expo
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# Lock files
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# Generated
|
||||
android
|
||||
ios
|
||||
.turbo
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -1,5 +1,139 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.32 - 2026-03-23
|
||||
|
||||
### Added
|
||||
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||
- Added line numbers and word-wrap toggle to file previews.
|
||||
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
|
||||
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
|
||||
- Expanded diff syntax highlighting to cover more languages.
|
||||
- Added status bar tooltips for project and agent status.
|
||||
|
||||
### Improved
|
||||
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
|
||||
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
|
||||
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
|
||||
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
|
||||
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
|
||||
- Desktop key repeat now works correctly on macOS.
|
||||
- Desktop notifications on macOS are more reliable.
|
||||
- Daemon startup no longer blocks on model downloads.
|
||||
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||
|
||||
### Fixed
|
||||
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||
- Fixed desktop window drag coordinates being applied when they shouldn't be.
|
||||
|
||||
## 0.1.30 - 2026-03-19
|
||||
|
||||
### Added
|
||||
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||
|
||||
### Improved
|
||||
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||
- Improved project picker path display and general workspace UI polish.
|
||||
|
||||
### Fixed
|
||||
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||
|
||||
## 0.1.28 - 2026-03-15
|
||||
|
||||
### Added
|
||||
- Added OpenCode build and plan modes.
|
||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||
|
||||
### Improved
|
||||
- Improved the git action menu for more reliable repository actions.
|
||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||
- Updated the website hero copy and added a sponsor callout section.
|
||||
|
||||
### Fixed
|
||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||
- Added Grep tool support in Claude tool-call mapping.
|
||||
- Added ability to open workspace files directly from agent chat messages.
|
||||
- Added desktop notifications via a custom native bridge.
|
||||
|
||||
### Improved
|
||||
- Improved image picker, markdown rendering, and UI interactions.
|
||||
- Improved shell environment detection using shell-env.
|
||||
|
||||
### Fixed
|
||||
- Fixed platform-specific markdown link rendering.
|
||||
- Fixed Linux AppImage CLI resource paths.
|
||||
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||
|
||||
## 0.1.26 - 2026-03-12
|
||||
|
||||
### Added
|
||||
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
|
||||
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
|
||||
- Added Windows support with improved cross-platform shell execution.
|
||||
|
||||
### Improved
|
||||
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
|
||||
- Added a Discord link to the website navigation.
|
||||
|
||||
### Fixed
|
||||
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
|
||||
- Fixed the home route to hide browser chrome when appropriate.
|
||||
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
|
||||
- Fixed noisy shell output interfering with executable lookup.
|
||||
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
|
||||
|
||||
## 0.1.25 - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
|
||||
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
|
||||
|
||||
## 0.1.24 - 2026-03-10
|
||||
|
||||
### Improved
|
||||
- Improved command center keyboard navigation and new tab shortcut.
|
||||
- Simplified desktop release pipeline for faster and more reliable builds.
|
||||
|
||||
## 0.1.21 - 2026-03-10
|
||||
### Improved
|
||||
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
|
||||
|
||||
### Fixed
|
||||
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
|
||||
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
|
||||
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
|
||||
|
||||
## 0.1.20 - 2026-03-10
|
||||
### Added
|
||||
- Added workspace sidebar git actions with quick diff stats and archive controls.
|
||||
- Added refreshed website downloads and homepage presentation for desktop installs.
|
||||
|
||||
### Improved
|
||||
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
|
||||
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
|
||||
|
||||
### Fixed
|
||||
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
|
||||
|
||||
## 0.1.19 - 2026-03-09
|
||||
### Added
|
||||
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
|
||||
|
||||
## 0.1.18 - 2026-03-06
|
||||
### Added
|
||||
- Added a desktop `Mod+W` shortcut to close the current tab.
|
||||
@@ -63,7 +197,7 @@
|
||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||
- Simplified website GitHub navigation and changelog headings.
|
||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||
- Hid empty filter groups in the left sidebar.
|
||||
|
||||
### Fixed
|
||||
@@ -85,7 +219,7 @@
|
||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||
- Improved desktop settings and permissions UX in Tauri.
|
||||
- Improved desktop settings and permissions UX on desktop.
|
||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||
|
||||
### Fixed
|
||||
@@ -124,7 +258,7 @@
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## 0.1.7 - 2026-02-16
|
||||
|
||||
266
CLAUDE.md
266
CLAUDE.md
@@ -1,252 +1,54 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
|
||||
|
||||
**Key features:**
|
||||
- Real-time streaming of agent output
|
||||
- Voice commands for hands-free interaction
|
||||
- Push notifications when tasks complete
|
||||
- Multi-agent orchestration across projects
|
||||
|
||||
**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Monorepo Structure
|
||||
## Repository map
|
||||
|
||||
This is an npm workspace monorepo:
|
||||
|
||||
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
|
||||
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
|
||||
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
|
||||
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
|
||||
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
|
||||
- `packages/app` — Mobile + web client (Expo)
|
||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||
- `packages/relay` — E2E encrypted relay for remote access
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Development Server
|
||||
## Documentation
|
||||
|
||||
The `npm run dev` script automatically picks an available port for the development server.
|
||||
| Doc | What's in it |
|
||||
|---|---|
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
- `PASEO_HOME` – path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
|
||||
|
||||
For trace+ logs, check $PASEO_HOME/daemon.log
|
||||
|
||||
## Running and checking logs
|
||||
|
||||
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Daemon and CLI
|
||||
|
||||
The Paseo daemon communicates via WebSocket. In the main checkout:
|
||||
- Daemon runs at `localhost:6767`
|
||||
- Expo app at `localhost:8081`
|
||||
- State lives in `$PASEO_HOME`
|
||||
|
||||
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
|
||||
|
||||
### Relay build sync (important)
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`.
|
||||
|
||||
### Server build sync for CLI (important)
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts.
|
||||
|
||||
### Quick reference CLI commands
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
```
|
||||
|
||||
### Agent state
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
Agent data is stored at:
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
## Critical rules
|
||||
|
||||
To find an agent by ID:
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **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.
|
||||
- **Always run typecheck after every change.**
|
||||
|
||||
To find an agent by title or other content:
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
rg -l "spiteful-toad" $PASEO_HOME/agents/
|
||||
```
|
||||
## Orchestrator mode
|
||||
|
||||
### Provider session files
|
||||
|
||||
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
|
||||
|
||||
**Claude sessions:**
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex sessions:**
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Android
|
||||
|
||||
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
|
||||
|
||||
### Android variants (vanilla Expo)
|
||||
|
||||
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
|
||||
|
||||
- `production` -> app name `Paseo`, package `sh.paseo`
|
||||
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
|
||||
|
||||
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
### Local build + install (Android device)
|
||||
|
||||
From `packages/app`:
|
||||
|
||||
```bash
|
||||
# development (debug)
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# production (release)
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# clean native project (when needed)
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development
|
||||
npm run android:production
|
||||
npm run android:clean
|
||||
```
|
||||
|
||||
`npm run android:release` is an alias for `npm run android:production`.
|
||||
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
- Build Android with the `production` profile
|
||||
- Submit each build with the `production` submit profile
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
# List recent mobile workflow runs
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect one run (jobs, status, outputs)
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for all steps in one failed job
|
||||
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||
|
||||
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
|
||||
|
||||
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||
|
||||
## Release playbook
|
||||
|
||||
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
|
||||
|
||||
```bash
|
||||
# Recommended: full patch release (bump, check, publish, push branch+tag)
|
||||
npm run release:patch
|
||||
|
||||
# Manual, step-by-step fallback:
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed. The notes are user-facing:
|
||||
- Ask yourself, what do Paseo users want to know about?
|
||||
- Include: New features, bug fixes
|
||||
- Don't include: Refactors or code changes that are not noticeable by users
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` 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 (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
|
||||
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
|
||||
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
|
||||
|
||||
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
|
||||
|
||||
## Agent Authentication
|
||||
|
||||
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
|
||||
|
||||
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
|
||||
|
||||
## NEVER DO THESE THINGS
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
|
||||
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
|
||||
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
|
||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
||||
- Launch agents in the most permissive mode
|
||||
- Set cwd to the repository root
|
||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# Execution Plan — Iteration 2 Projects → Workspaces → Tabs (Paseo Orchestrated)
|
||||
|
||||
This document describes **how** we will execute the fixes defined in:
|
||||
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
|
||||
|
||||
It is optimized for parallel work using **Paseo-managed worktrees** and strict quality gates.
|
||||
|
||||
---
|
||||
|
||||
## Constraints / Guardrails
|
||||
|
||||
- **Do not restart or modify** the user’s main daemon on `localhost:6767`.
|
||||
- Use **isolated dev stacks** for manual verification (new daemon + new Metro) via:
|
||||
- `PASEO_HOME=~/.paseo-<unique>` and `npm run dev` (auto-picks free ports).
|
||||
- No “legacy view” preserved: we fix the current UX directly (no dead/unused code paths left behind).
|
||||
- Agents must treat **terminals and agents as equal first-class tab types** (no special layouts).
|
||||
- Keep changes focused to the reported issues; avoid unrelated refactors.
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown (Parallel)
|
||||
|
||||
### Agent A — Sidebar drag scoping + sidebar polish
|
||||
|
||||
**Worktree:** `polish/sidebar-dnd-and-style`
|
||||
|
||||
Responsibilities:
|
||||
- Fix project drag so dragging a **project header** reorders the **entire project section** (header + workspaces).
|
||||
- Fix workspace drag so workspaces reorder **only within their project** (no cross-project placement).
|
||||
- Ensure sidebar list **snaps back** to canonical `Project → Workspaces` structure after any drag.
|
||||
- Sidebar visuals:
|
||||
- remove workspace “border” style, match project “ghost” style language
|
||||
- remove “No agents yet”
|
||||
- reduce workspace indentation/padding (mobile-friendly)
|
||||
- Navigation polish:
|
||||
- clicking a workspace closes the left sidebar (mobile)
|
||||
|
||||
### Agent B — Workspace header + tabs polish (icons, unified create, persistence)
|
||||
|
||||
**Worktree:** `polish/workspace-tabs-and-header`
|
||||
|
||||
Responsibilities:
|
||||
- Workspace header shows **branch name** for git workspaces (including base branch like `main`).
|
||||
- Replace separate “create agent” vs “create terminal” rows with **one unified New Tab control** (agent + terminal).
|
||||
- Agent tabs show **provider icons** (Claude + Codex minimum, using existing assets/components).
|
||||
- Fix “remember focused tab per workspace” so:
|
||||
- switching away and back restores the last focused agent/terminal tab
|
||||
- stored selection is **not overwritten** while agent/terminal lists are still loading
|
||||
|
||||
### Agent C — Review / sanity check (no code changes)
|
||||
|
||||
Runs after merges to:
|
||||
- review diff for edge cases + regressions
|
||||
- double-check acceptance criteria mapping
|
||||
- call out missing verification steps
|
||||
|
||||
---
|
||||
|
||||
## Agent Launch Commands (local CLI)
|
||||
|
||||
We use the repo-local CLI:
|
||||
|
||||
```bash
|
||||
npm run -s cli -- run --provider codex --model gpt-5.3-codex --mode full-access --worktree <name> --name "<title>" --detach "<prompt>" --quiet
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--detach --quiet` returns the agent ID quickly so we can launch in parallel.
|
||||
- Each agent must **commit** their work in their worktree branch before finishing.
|
||||
|
||||
---
|
||||
|
||||
## Prompts (exact)
|
||||
|
||||
### Prompt for Agent A
|
||||
|
||||
Title: `🎭 Sidebar DnD + Polish`
|
||||
|
||||
Prompt:
|
||||
- Implement **only** the items in “Sidebar drag behavior” + “Sidebar visuals + navigation polish” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Do not change gestures beyond the required drag constraints.
|
||||
- Ensure the post-drag list snaps back to the canonical project/workspace grouping.
|
||||
- Remove “No agents yet” and fix workspace row styling/indentation.
|
||||
- Close sidebar on workspace selection (mobile).
|
||||
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
|
||||
- Commit with a clear message.
|
||||
|
||||
### Prompt for Agent B
|
||||
|
||||
Title: `🎭 Workspace Tabs + Header`
|
||||
|
||||
Prompt:
|
||||
- Implement **only** the items in “Workspace header + tab bar fixes” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Terminals and agents must be treated as identical first-class tab types (no separate rows/layout).
|
||||
- Add provider icons for agent tabs (Claude/Codex minimum) using existing app icon components.
|
||||
- Fix per-workspace focused-tab persistence (don’t overwrite selection while queries are pending).
|
||||
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
|
||||
- Commit with a clear message.
|
||||
|
||||
### Prompt for Agent C (review-only)
|
||||
|
||||
Title: `🎭 Review: Sidebar + Tabs Polish`
|
||||
|
||||
Prompt:
|
||||
- Review the combined diff for correctness vs acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- DO NOT edit code. Provide a checklist of anything missing or risky.
|
||||
|
||||
---
|
||||
|
||||
## Merge Strategy (back to `main`)
|
||||
|
||||
1. Wait for Agents A + B to complete.
|
||||
2. For each worktree branch:
|
||||
- verify it has a clean commit history (no unrelated changes)
|
||||
- re-run `npm run typecheck` if needed
|
||||
3. Merge into `main` sequentially:
|
||||
- merge A
|
||||
- rebase/merge B on top of updated `main` (resolve conflicts if any)
|
||||
4. Do not delete/prune worktrees until the user has manually verified.
|
||||
|
||||
---
|
||||
|
||||
## Verification Gates (strict)
|
||||
|
||||
### 1) Automated (must pass)
|
||||
|
||||
From repo root on `main` after merges:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
Optional (run if environment supports it; starts isolated daemon/metro itself):
|
||||
|
||||
```bash
|
||||
npm run test:e2e --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
### 2) Manual (must be performed by us before handing back)
|
||||
|
||||
Use an **isolated dev stack** (new daemon + new Metro):
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-iter2-polish npm run dev
|
||||
```
|
||||
|
||||
Then use `agent-browser` to verify the “Manual (agent-browser)” section in:
|
||||
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
|
||||
|
||||
---
|
||||
|
||||
## Completion Definition
|
||||
|
||||
We are “done” when:
|
||||
- All acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` are met.
|
||||
- Automated verification gates pass.
|
||||
- Manual verification steps pass.
|
||||
- Changes are merged into `main` with no leftover legacy code paths.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# Iteration 3 Execution Plan — Paseo Orchestrated
|
||||
|
||||
## Principles
|
||||
|
||||
- Work happens in an isolated git worktree and merges back to `main` once verified.
|
||||
- Do not restart or touch the daemon on `localhost:6767`.
|
||||
- Use the repo’s existing Playwright global setup (isolated daemon/metro) for E2E.
|
||||
- Pass all gates before merging:
|
||||
- Typecheck
|
||||
- Vitest
|
||||
- Playwright E2E
|
||||
- Manual `agent-browser` verification
|
||||
|
||||
## Agent Delegation (Paseo)
|
||||
|
||||
### Implementation agent (1)
|
||||
|
||||
- **Agent:** Codex
|
||||
- **Mode:** full-access
|
||||
- **Worktree:** `iter3-workspace-header-tabs-restore`
|
||||
- **Mission:**
|
||||
- Fix New tab dropdown visibility (on-screen, correct pattern)
|
||||
- Restore workspace header structure + explorer toggle + agent kebab menu
|
||||
- Add terminal close (X) from workspace tab strip
|
||||
- Add/adjust Playwright E2E specs for the above
|
||||
- Run verification commands before declaring done
|
||||
|
||||
### Reviewer (optional, if needed)
|
||||
|
||||
- Only used if implementation is large/risky or tests expose subtle regressions.
|
||||
- Codex or Claude Sonnet as a second-pass reviewer for UI regressions.
|
||||
|
||||
## Merge Strategy
|
||||
|
||||
1. Agent commits all changes in the worktree.
|
||||
2. Orchestrator reviews the diff on the worktree.
|
||||
3. Run gates locally on the worktree:
|
||||
- `npm run typecheck`
|
||||
- `npm run test --workspace=@getpaseo/app`
|
||||
- `npm run test:e2e --workspace=@getpaseo/app`
|
||||
4. Manual `agent-browser` verification:
|
||||
- Desktop viewport: New tab menu visible + explorer toggle + kebab menu
|
||||
- Mobile viewport: explorer icon uses git/folder, toggles right sidebar, left sidebar unaffected
|
||||
5. Merge worktree back into `main` with a fast-forward merge if possible; otherwise merge commit.
|
||||
|
||||
## Verification Checklist (strict)
|
||||
|
||||
- [ ] New tab menu opens and is visible (desktop)
|
||||
- [ ] Selecting Agent tab routes to draft agent flow scoped to workspace
|
||||
- [ ] Selecting Terminal tab creates terminal and focuses it
|
||||
- [ ] Terminal tabs show X; closing kills terminal and removes tab
|
||||
- [ ] Workspace header shows branch name
|
||||
- [ ] Workspace header has explorer toggle with correct icon behavior
|
||||
- [ ] Agent kebab menu exists when Agent tab active
|
||||
- [ ] Right sidebar opens/closes via header and mobile swipe gesture
|
||||
- [ ] Left sidebar gestures unchanged
|
||||
- [ ] `npm run typecheck` ✅
|
||||
- [ ] `npm run test --workspace=@getpaseo/app` ✅
|
||||
- [ ] `npm run test:e2e --workspace=@getpaseo/app` ✅
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Execution Plan — Iteration 4 (Paseo-orchestrated)
|
||||
|
||||
## Strategy
|
||||
|
||||
Use a Paseo-managed implementation agent in an isolated git worktree to patch the workspace screen to restore legacy header/layout parity while keeping tabs. Then review, validate with tests + agent-browser, and merge back to `main`.
|
||||
|
||||
## Agents
|
||||
|
||||
### 1) Implementation agent (Codex)
|
||||
|
||||
- Provider/model: `codex / gpt-5.3-codex`
|
||||
- Mode: `full-access`
|
||||
- Worktree: `iter4-workspace-header-layout-restore` (base: `main`)
|
||||
- Responsibilities:
|
||||
- Fix `New tab` dropdown to use the established dropdown/menu pattern (not off-screen combobox).
|
||||
- Restore workspace header explorer toggle parity with legacy `AgentReadyScreen` (icons, aria state).
|
||||
- Restore agent overflow (kebab) menu when active tab is an agent.
|
||||
- Add terminal tab close `X` on desktop with confirm + kill terminal mutation.
|
||||
- Add/extend Playwright E2E specs for `New tab` on-screen + explorer toggle open/close.
|
||||
- Commit changes.
|
||||
|
||||
### 2) Reviewer/validator (you/me)
|
||||
|
||||
- Review diffs locally.
|
||||
- Run:
|
||||
- `npm run typecheck`
|
||||
- `npm run test --workspace=@getpaseo/app`
|
||||
- Targeted Playwright spec(s) for this iteration
|
||||
- Perform agent-browser manual verification (desktop + mobile viewports).
|
||||
|
||||
## Tooling / Commands (canonical)
|
||||
|
||||
### Create agent (detached)
|
||||
|
||||
```bash
|
||||
paseo run -d \
|
||||
--worktree iter4-workspace-header-layout-restore \
|
||||
--base main \
|
||||
--provider codex \
|
||||
--model gpt-5.3-codex \
|
||||
--mode full-access \
|
||||
--name "🎭 Iter4 workspace header/layout restore" \
|
||||
"<paste the implementation prompt>"
|
||||
```
|
||||
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
paseo wait <agent-id>
|
||||
```
|
||||
|
||||
### Review worktree diff
|
||||
|
||||
```bash
|
||||
cd ~/.paseo/worktrees/<hash>/iter4-workspace-header-layout-restore
|
||||
git status --short --branch
|
||||
git log -n 5 --oneline
|
||||
git diff main..HEAD
|
||||
```
|
||||
|
||||
### Merge into main
|
||||
|
||||
Prefer `git cherry-pick <commit>` into `/Users/moboudra/dev/paseo` `main` after verification.
|
||||
|
||||
## Verification gates (must be green)
|
||||
|
||||
### 1) Typecheck
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### 2) App unit tests
|
||||
|
||||
```bash
|
||||
npm run test --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
### 3) Playwright E2E (targeted)
|
||||
|
||||
Run only the spec(s) for this iteration (avoid unrelated flakes):
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
npx playwright test e2e/workspace-header-tabs-restore.spec.ts
|
||||
```
|
||||
|
||||
## Manual verification (agent-browser)
|
||||
|
||||
Use explicit sessions:
|
||||
|
||||
```bash
|
||||
agent-browser --session iter4-desktop open http://localhost:8081
|
||||
agent-browser --session iter4-mobile open http://localhost:8081
|
||||
```
|
||||
|
||||
Desktop:
|
||||
- Validate `New tab` dropdown opens on-screen
|
||||
- Validate explorer toggle opens/closes explorer
|
||||
- Validate kebab menu appears for agent tabs
|
||||
- Validate terminal tab `X` close flow
|
||||
|
||||
Mobile viewport:
|
||||
- Validate git/folder icon for explorer toggle
|
||||
- Validate gestures for left/right sidebars
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If verification fails:
|
||||
- Do not merge.
|
||||
- Patch in worktree until acceptance criteria + gates pass.
|
||||
- Only then cherry-pick/merge into `main`.
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# Execution Plan (Paseo Orchestrator)
|
||||
|
||||
This document is the concrete execution plan for `PLAN_PROJECTS_WORKSPACES_TABS.md`, using the Paseo CLI to delegate work to sub-agents running inside isolated git worktrees.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All agent names are prefixed with `🎭` so they’re easy to identify.
|
||||
- Use **Codex** for implementation work (`--provider codex --mode full-access`).
|
||||
- Each agent runs in its **own worktree** to avoid concurrent writes to the same git working directory.
|
||||
- Agents must avoid “legacy/compat mode” code paths: we are fully redirecting/reworking.
|
||||
|
||||
## 0) Preflight
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
npm run -s cli -- daemon status
|
||||
```
|
||||
|
||||
Expected:
|
||||
- current branch is `main` (or the branch you want as base)
|
||||
- daemon is reachable
|
||||
|
||||
## 1) Launch parallel agents (detached)
|
||||
|
||||
### Agent A — Server protocol + workspace data
|
||||
|
||||
Scope:
|
||||
- Replace file explorer + download token RPC to be **workspace-scoped** (remove `agentId` usage).
|
||||
- Extend worktree list payload with `createdAt` (and a stable `workspaceId` if needed).
|
||||
- Ensure agent index loads once at daemon startup (no repeated disk hydration).
|
||||
|
||||
```bash
|
||||
SERVER_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT Server: workspace RPCs" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-server-workspace-rpcs --base main \
|
||||
"Implement server-side changes from PLAN_PROJECTS_WORKSPACES_TABS.md: replace file_explorer_request and file_download_token_request to be workspace-scoped (no agentId), update handlers + client types, add createdAt to paseo_worktree_list_response, and make agent storage/index load once at daemon startup. Do NOT add legacy compatibility. Keep changes minimal and typecheck. Output a short checklist of touched files + how to test."
|
||||
)
|
||||
echo "$SERVER_ID"
|
||||
```
|
||||
|
||||
### Agent B — App: workspace routes + tabs main view
|
||||
|
||||
Scope:
|
||||
- Add workspace routes/screens and redirect old agent routes.
|
||||
- Implement workspace header + tab bar (agent + terminal tabs).
|
||||
- Persist/restore last focused tab per workspace.
|
||||
- Mobile tab switcher + plus button (draft agent flow pre-scoped).
|
||||
|
||||
```bash
|
||||
APP_TABS_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: workspace tabs" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-workspace-tabs --base main \
|
||||
"Implement app-side workspace main view + tabs from PLAN_PROJECTS_WORKSPACES_TABS.md. Replace old /h/:serverId/agent routes with workspace routes, render a workspace screen with a horizontal tab bar (agent + terminal tabs), restore last focused tab, and implement a mobile tab switcher + header plus button to open draft agent flow pre-scoped to workspace. Terminals are just tabs; no special casing. Do NOT keep legacy UI. Keep gestures/overlay sidebars unchanged. Typecheck."
|
||||
)
|
||||
echo \"$APP_TABS_ID\"
|
||||
```
|
||||
|
||||
### Agent C — App: left sidebar projects → workspaces
|
||||
|
||||
Scope:
|
||||
- Replace left sidebar agent list with Project → Workspace tree.
|
||||
- Project icon, status dot aggregation, reorder persistence.
|
||||
- Workspace rows: branch + createdAt, no path.
|
||||
|
||||
```bash
|
||||
APP_SIDEBAR_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: projects sidebar" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-projects-sidebar --base main \
|
||||
"Implement the left sidebar rewrite per PLAN_PROJECTS_WORKSPACES_TABS.md: Projects grouped by projectKey (remote when available else local), each project shows icon + status dot, and contains workspaces (main checkout + Paseo worktrees incl empty). Workspace row shows branch name + createdAt only (no path). Keep drag reorder for projects + workspaces and persist on-device. Do not show agents in sidebar. Typecheck."
|
||||
)
|
||||
echo \"$APP_SIDEBAR_ID\"
|
||||
```
|
||||
|
||||
### Agent D — App: right sidebar changes + files (workspace-scoped)
|
||||
|
||||
Scope:
|
||||
- Remove terminals from the right sidebar.
|
||||
- Make Changes/Files sidebar workspace-scoped (not agent-scoped).
|
||||
- Update file explorer calls to use new workspace-scoped RPC.
|
||||
|
||||
```bash
|
||||
APP_EXPLORER_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: explorer sidebar" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-explorer-sidebar --base main \
|
||||
"Update the right sidebar per PLAN_PROJECTS_WORKSPACES_TABS.md: it must contain only Changes + Files and be scoped to the opened workspace (not the selected tab). Remove terminals from the right sidebar entirely. Replace file explorer client actions/state to use the new workspace-scoped RPC (no agentId). Ensure empty workspaces still work. Typecheck."
|
||||
)
|
||||
echo \"$APP_EXPLORER_ID\"
|
||||
```
|
||||
|
||||
## 2) Wait for completion
|
||||
|
||||
```bash
|
||||
npm run -s cli -- wait "$SERVER_ID"
|
||||
npm run -s cli -- wait "$APP_TABS_ID"
|
||||
npm run -s cli -- wait "$APP_SIDEBAR_ID"
|
||||
npm run -s cli -- wait "$APP_EXPLORER_ID"
|
||||
```
|
||||
|
||||
## 3) Collect diffs from each worktree
|
||||
|
||||
```bash
|
||||
SERVER_CWD=$(npm run -s cli -- inspect "$SERVER_ID" --json | jq -r '.cwd')
|
||||
APP_TABS_CWD=$(npm run -s cli -- inspect "$APP_TABS_ID" --json | jq -r '.cwd')
|
||||
APP_SIDEBAR_CWD=$(npm run -s cli -- inspect "$APP_SIDEBAR_ID" --json | jq -r '.cwd')
|
||||
APP_EXPLORER_CWD=$(npm run -s cli -- inspect "$APP_EXPLORER_ID" --json | jq -r '.cwd')
|
||||
|
||||
git -C "$SERVER_CWD" diff > /tmp/pwt-server.patch
|
||||
git -C "$APP_TABS_CWD" diff > /tmp/pwt-app-tabs.patch
|
||||
git -C "$APP_SIDEBAR_CWD" diff > /tmp/pwt-app-sidebar.patch
|
||||
git -C "$APP_EXPLORER_CWD" diff > /tmp/pwt-app-explorer.patch
|
||||
```
|
||||
|
||||
## 4) Integrate (apply patches in order)
|
||||
|
||||
Recommended: create a clean integration worktree/branch first, then apply patches.
|
||||
|
||||
```bash
|
||||
# In a clean integration branch/worktree:
|
||||
git apply /tmp/pwt-server.patch
|
||||
git apply /tmp/pwt-app-tabs.patch
|
||||
git apply /tmp/pwt-app-sidebar.patch
|
||||
git apply /tmp/pwt-app-explorer.patch
|
||||
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
If `git apply` fails due to overlap, apply one patch at a time and resolve manually, then re-run typecheck.
|
||||
|
||||
## 5) QA pass
|
||||
|
||||
- Verify acceptance criteria list in `PLAN_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Smoke test navigation: open workspace → tabs → right sidebar → plus flow.
|
||||
- Confirm no remaining agentId-based explorer/download usage.
|
||||
|
||||
22
README.md
22
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -12,19 +12,27 @@
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
|
||||
## 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.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
@@ -36,7 +44,7 @@ Quick monorepo package map:
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
@@ -57,4 +65,4 @@ npm run typecheck
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
32
biome.json
Normal file
32
biome.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!*.lock"]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
1
cli-client-id
Normal file
1
cli-client-id
Normal file
@@ -0,0 +1 @@
|
||||
cid_518a41c4c44340aea1120d2b760fc6c6
|
||||
67
docs/ANDROID.md
Normal file
67
docs/ANDROID.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Android
|
||||
|
||||
## App variants
|
||||
|
||||
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
|
||||
|
||||
| Variant | App name | Package ID |
|
||||
|---|---|---|
|
||||
| `production` | Paseo | `sh.paseo` |
|
||||
| `development` | Paseo Debug | `sh.paseo.debug` |
|
||||
|
||||
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
## Local build + install
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
```bash
|
||||
adb exec-out screencap -p > screenshot.png
|
||||
```
|
||||
|
||||
## Cloud build + submit (EAS)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- `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)
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# List recent workflow runs
|
||||
npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect a run
|
||||
npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for a failed job
|
||||
npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
184
docs/ARCHITECTURE.md
Normal file
184
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Architecture
|
||||
|
||||
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
│ (direct or │ (direct) │ + WebSocket
|
||||
│ via relay) │ │
|
||||
└───────────┬───────┴──────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Daemon │
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### `packages/server` — The daemon
|
||||
|
||||
The heart of Paseo. A Node.js process that:
|
||||
|
||||
- Listens for WebSocket connections from clients
|
||||
- Manages agent lifecycle (create, run, stop, resume, archive)
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
||||
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
||||
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands:
|
||||
|
||||
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree ls/archive`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
|
||||
**Binary multiplexing:**
|
||||
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
|---|---|---|
|
||||
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode CLI | Provider-managed |
|
||||
|
||||
All providers:
|
||||
- Handle their own authentication (Paseo does not manage API keys)
|
||||
- Support session resume via persistence handles
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
2. Session routes to `AgentManager.create()`
|
||||
3. AgentManager creates a `ManagedAgent`, initializes provider session
|
||||
4. Provider runs the agent → emits `AgentStreamEvent` items
|
||||
5. Events append to the agent timeline, broadcast to all subscribed clients
|
||||
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
|
||||
7. Permission requests flow: agent → server → client → user decision → server → agent
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
└── daemon.log # Daemon trace logs
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
174
docs/CODING_STANDARDS.md
Normal file
174
docs/CODING_STANDARDS.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Coding Standards
|
||||
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — justify every abstraction with specific benefits
|
||||
- **Fully typed TypeScript** — no `any`, no untyped boundaries
|
||||
- **YAGNI** — build features and abstractions only when needed
|
||||
- **Functional and declarative** over object-oriented
|
||||
- **`interface`** over `type` when possible
|
||||
- **`function` declarations** over arrow function assignments
|
||||
- **Single-purpose functions** — one function, one job
|
||||
- **Design for edge cases through types** rather than explicit handling
|
||||
- **Don't catch errors** unless there's a strong reason to
|
||||
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
|
||||
- **No "while I'm at it" improvements** — stay focused on the task
|
||||
|
||||
## Type hygiene
|
||||
|
||||
### Infer from schemas
|
||||
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicate type that can drift
|
||||
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
|
||||
type RPCArgs = { procedure: string; args: Record<string, unknown> };
|
||||
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
|
||||
### Named types over inline
|
||||
|
||||
No complex inline types in public function signatures.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
|
||||
### Object parameters
|
||||
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
|
||||
### One canonical type per concept
|
||||
|
||||
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicated fields across layers
|
||||
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
|
||||
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
|
||||
|
||||
// Good: canonical type + wrappers
|
||||
type ToolCall = { toolName: string; args: Record<string, unknown> };
|
||||
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
|
||||
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
|
||||
```
|
||||
|
||||
## Make impossible states impossible
|
||||
|
||||
Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "success"; data: Data };
|
||||
```
|
||||
|
||||
## Optionality is a design decision
|
||||
|
||||
Don't mark fields optional to avoid migrations. Decide deliberately:
|
||||
|
||||
1. Is optionality actually needed?
|
||||
2. If there are distinct valid states → discriminated union
|
||||
3. If value can be intentionally empty → explicit `null`
|
||||
4. Keep optionality at real boundaries (external input), then resolve it
|
||||
|
||||
## Validate at boundaries, trust internally
|
||||
|
||||
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
|
||||
|
||||
```typescript
|
||||
// Bad: optional chaining because shape is unclear
|
||||
const value = response?.data?.items?.[0]?.name;
|
||||
|
||||
// Good: validate at boundary, trust the types
|
||||
const parsed = responseSchema.parse(rawResponse);
|
||||
const value = parsed.data.items[0].name;
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
|
||||
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
|
||||
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
|
||||
|
||||
```typescript
|
||||
class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly operation: string,
|
||||
public readonly waitedMs: number,
|
||||
) {
|
||||
super(`${operation} timed out after ${waitedMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep logic density low
|
||||
|
||||
Avoid packing branching, lookup, and transformation into single dense expressions.
|
||||
|
||||
```typescript
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(account, rates.find((r) => r.region === account.region));
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
if (!rate) throw new MissingRateError(account.region);
|
||||
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
|
||||
```
|
||||
|
||||
## Centralize policy
|
||||
|
||||
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
|
||||
|
||||
## React: keep components dumb
|
||||
|
||||
- Components render state and dispatch events — they don't compute transitions
|
||||
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
|
||||
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
|
||||
- Never mirror a source of truth into local state; derive from it
|
||||
- Test state logic as pure functions without rendering
|
||||
|
||||
## File organization
|
||||
|
||||
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
|
||||
- Name files after the main export (`create-toolcall.ts`)
|
||||
- Use `index.ts` as an entrypoint, not a dumping ground
|
||||
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
|
||||
|
||||
## Refactoring contract
|
||||
|
||||
Refactoring is structure work, not feature work.
|
||||
|
||||
- Preserve behavior by default, especially user-facing behavior
|
||||
- Do not remove features to simplify code without explicit approval
|
||||
- Have a verification strategy before you start
|
||||
- Fully migrate callers and remove old paths in the same refactor
|
||||
- No fallback behavior by default — prefer explicit error over silent degradation
|
||||
73
docs/DESIGN.md
Normal file
73
docs/DESIGN.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Designing Features
|
||||
|
||||
How to think through a feature before writing code.
|
||||
|
||||
## Start from the user
|
||||
|
||||
Even for backend work, start from the user's perspective:
|
||||
|
||||
- What problem does this solve?
|
||||
- What triggers it? User action, schedule, event?
|
||||
- What does success look like from the user's perspective?
|
||||
- What data does it need? Where does that data come from?
|
||||
|
||||
## Map existing code
|
||||
|
||||
Before designing anything new, understand what exists:
|
||||
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
|
||||
- What types and data shapes are already defined?
|
||||
|
||||
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
|
||||
|
||||
## Define verification before implementation
|
||||
|
||||
Before designing the solution, define how you'll know it works:
|
||||
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
|
||||
## Design the shape
|
||||
|
||||
### Data
|
||||
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
|
||||
|
||||
### Layers
|
||||
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
|
||||
### Interactions
|
||||
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
|
||||
### Refactoring
|
||||
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
|
||||
## Create a concrete plan
|
||||
|
||||
Once the design is clear:
|
||||
|
||||
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
|
||||
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
|
||||
3. **What to refactor** before adding new code
|
||||
4. **How to verify** each step
|
||||
|
||||
## Principles
|
||||
|
||||
- **Fit, don't force** — new code should fit existing patterns, or refactor first
|
||||
- **Simple** — the best design is the simplest one that works
|
||||
- **Verify early** — define how to test before designing the implementation
|
||||
130
docs/DEVELOPMENT.md
Normal file
130
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (see `.tool-versions` for exact version)
|
||||
- npm workspaces (comes with Node)
|
||||
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
|
||||
|
||||
### Running alongside the main checkout
|
||||
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||
|
||||
### Default ports
|
||||
|
||||
In the main checkout:
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
|
||||
### Server → CLI
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Find an agent by ID:
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
|
||||
Find by content:
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
```
|
||||
|
||||
## Provider session files
|
||||
|
||||
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex:**
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
npx expo-doctor
|
||||
```
|
||||
|
||||
Diagnoses version mismatches and native module issues.
|
||||
|
||||
## Typecheck
|
||||
|
||||
Always run typecheck after changes:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
74
docs/PRODUCT.md
Normal file
74
docs/PRODUCT.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
|
||||
## What is Paseo
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
|
||||
## Core philosophy
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
### Projects and workspaces
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
|
||||
### Inside a workspace
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
|
||||
### The daemon
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
|
||||
This architecture means:
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
|
||||
## Target user
|
||||
|
||||
Anyone who builds software:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
|
||||
## What compounds over time
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
|
||||
## Strategic bets
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
73
docs/RELEASE.md
Normal file
73
docs/RELEASE.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Release
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
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:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Draft release flow
|
||||
|
||||
```bash
|
||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||
npm run release:finalize # Publish npm, promote draft to published
|
||||
```
|
||||
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||
|
||||
```bash
|
||||
# Desktop (all platforms)
|
||||
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
|
||||
|
||||
# Desktop (single platform)
|
||||
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
|
||||
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
|
||||
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
|
||||
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] `npm run release:patch` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` 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
|
||||
123
docs/TESTING.md
Normal file
123
docs/TESTING.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Testing
|
||||
|
||||
## Philosophy
|
||||
|
||||
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
|
||||
|
||||
## Test-driven development
|
||||
|
||||
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
|
||||
|
||||
```
|
||||
RIGHT (vertical):
|
||||
RED→GREEN: test1→impl1
|
||||
RED→GREEN: test2→impl2
|
||||
RED→GREEN: test3→impl3
|
||||
|
||||
WRONG (horizontal):
|
||||
RED: test1, test2, test3, test4, test5
|
||||
GREEN: impl1, impl2, impl3, impl4, impl5
|
||||
```
|
||||
|
||||
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
|
||||
|
||||
## Determinism first
|
||||
|
||||
Tests must produce the same result every run:
|
||||
|
||||
- No conditional assertions or branching paths
|
||||
- No reliance on timing, randomness, or network jitter
|
||||
- No weak assertions (`toBeTruthy`, `toBeDefined`)
|
||||
- Assert the full intended behavior, not fragments
|
||||
|
||||
```typescript
|
||||
// Bad: conditional and weak
|
||||
it("creates a tool call", async () => {
|
||||
const result = await createToolCall(input);
|
||||
if (result.ok) {
|
||||
expect(result.id).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
// Good: deterministic and explicit
|
||||
it("returns timeout error when provider times out", async () => {
|
||||
const result = await createToolCall(input);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Flaky tests are a bug
|
||||
|
||||
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
|
||||
|
||||
## Real dependencies over mocks
|
||||
|
||||
Mocks are not the default. They require an explicit decision.
|
||||
|
||||
- **Database**: real test database, not a mock
|
||||
- **APIs**: real APIs with test/sandbox credentials, not request mocks
|
||||
- **File system**: temporary directory that gets cleaned up, not fs mocks
|
||||
|
||||
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
|
||||
|
||||
### Use swappable adapters instead
|
||||
|
||||
When you need test isolation, design code so dependencies are injectable:
|
||||
|
||||
```typescript
|
||||
interface EmailSender {
|
||||
send(to: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Production
|
||||
const realSender: EmailSender = { send: sendgrid.send };
|
||||
|
||||
// Test: in-memory adapter
|
||||
function createTestEmailSender() {
|
||||
const sent: Array<{ to: string; body: string }> = [];
|
||||
return {
|
||||
send: async (to: string, body: string) => { sent.push({ to, body }); },
|
||||
sent,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## End-to-end means end-to-end
|
||||
|
||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
|
||||
|
||||
## Debugging with tests
|
||||
|
||||
Use the test as your debugging ground:
|
||||
|
||||
1. Add temporary logging to the code under test
|
||||
2. Run the test, observe actual values
|
||||
3. Trace the flow end-to-end through test output
|
||||
4. Confirm each assumption with actual output
|
||||
5. Remove logging when done
|
||||
|
||||
The test output is the source of truth, not your reading of the code.
|
||||
|
||||
## Design for testability
|
||||
|
||||
If code isn't testable, refactor it. Signs:
|
||||
- You want to reach for a mock
|
||||
- You can't inject a dependency
|
||||
- You need to test private internals
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
19789
package-lock.json
generated
19789
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.32",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -12,22 +14,24 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
@@ -44,16 +48,23 @@
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "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",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run version:all:minor && npm run release:check && 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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
@@ -68,9 +79,12 @@
|
||||
"author": "moboudra",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1"
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,7 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
infoPlist: {
|
||||
NSMicrophoneUsageDescription:
|
||||
"This app needs access to the microphone for voice commands.",
|
||||
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
bundleIdentifier: variant.packageId,
|
||||
@@ -92,14 +91,15 @@ export default {
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
package: variant.packageId,
|
||||
...(variant.googleServicesFile
|
||||
? { googleServicesFile: variant.googleServicesFile }
|
||||
: {}),
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +143,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
Binary file not shown.
@@ -1,35 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SERVER_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
||||
const AGENT_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
||||
const DAEMON_ENDPOINT =
|
||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
test.use({ browserName: "firefox" });
|
||||
|
||||
function seedDaemonRegistryScript(params: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const daemon = {
|
||||
serverId: params.serverId,
|
||||
label: "localhost",
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${params.endpoint}`,
|
||||
type: "direct",
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${params.endpoint}`,
|
||||
createdAt: params.nowIso,
|
||||
updatedAt: params.nowIso,
|
||||
};
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
serverId: params.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollUpFromBottom(
|
||||
page: Page,
|
||||
pixels: number
|
||||
): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [
|
||||
rootElement,
|
||||
...Array.from(rootElement.querySelectorAll("*")),
|
||||
];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toContain("Firefox");
|
||||
|
||||
await page.addInitScript(seedDaemonRegistryScript, {
|
||||
serverId: SERVER_ID,
|
||||
endpoint: DAEMON_ENDPOINT,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Require enough history so the repro actually scrolls away from bottom.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
||||
})
|
||||
.toBeGreaterThan(300);
|
||||
|
||||
await scrollUpFromBottom(page, 900);
|
||||
await page.waitForTimeout(250);
|
||||
const beforeSubmit = await readScrollMetrics(page);
|
||||
const beforePath = testInfo.outputPath("before-submit.png");
|
||||
await page.screenshot({ path: beforePath, fullPage: true });
|
||||
await testInfo.attach("before-submit", {
|
||||
path: beforePath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const afterSubmit = await readScrollMetrics(page);
|
||||
const afterPath = testInfo.outputPath("after-submit.png");
|
||||
await page.screenshot({ path: afterPath, fullPage: true });
|
||||
await testInfo.attach("after-submit", {
|
||||
path: afterPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("firefox-scroll-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
url: AGENT_URL,
|
||||
submitText: SUBMIT_TEXT,
|
||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
||||
beforeScreenshot: beforePath,
|
||||
afterScreenshot: afterPath,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[firefox-scroll-repro] ${JSON.stringify(
|
||||
{
|
||||
submitText: SUBMIT_TEXT,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
||||
NEAR_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
const menu = page.getByTestId('changes-diff-status-menu');
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const optionTestId =
|
||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
||||
await page.getByTestId(optionTestId).click({ force: true });
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
const changesScope = getChangesScope(page);
|
||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(branchName);
|
||||
}
|
||||
|
||||
const preferredOption = menu
|
||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
||||
.first();
|
||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
||||
await preferredOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (/Create new worktree/i.test(currentValue)) {
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await createOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
await selectChangesView(page, 'base');
|
||||
await selectChangesView(page, 'working');
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
if (value.startsWith('/var/')) {
|
||||
return `/private${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
worktreePath: string;
|
||||
branchRef: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
||||
const blocks = raw
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
for (const block of blocks) {
|
||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
||||
if (!worktreeMatch) {
|
||||
continue;
|
||||
}
|
||||
const branchMatch = block.match(/^branch (.+)$/m);
|
||||
entries.push({
|
||||
worktreePath: worktreeMatch[1].trim(),
|
||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const entries = parseGitWorktreeList(output);
|
||||
const candidate = entries.find((entry) => {
|
||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.branchRef) {
|
||||
return false;
|
||||
}
|
||||
return !/\/main$/i.test(entry.branchRef);
|
||||
});
|
||||
|
||||
if (candidate) {
|
||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
||||
if (branchName) {
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient git worktree read errors while polling.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
]);
|
||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
|
||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgentInRepo } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
||||
|
||||
// Verify the assistant response is rendered.
|
||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
openSettings,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from '@/utils/host-routes';
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
active: 0,
|
||||
getUserMediaCalls: 0,
|
||||
stopCalls: 0,
|
||||
lastRecorder: null as null | { state: string },
|
||||
};
|
||||
(window as any).__mic = mic;
|
||||
|
||||
(window as any).isSecureContext = true;
|
||||
|
||||
const nav = navigator as any;
|
||||
if (!nav.mediaDevices) {
|
||||
nav.mediaDevices = {};
|
||||
}
|
||||
nav.mediaDevices.getUserMedia = async () => {
|
||||
mic.getUserMediaCalls += 1;
|
||||
mic.active += 1;
|
||||
const track = {
|
||||
stop: () => {
|
||||
mic.stopCalls += 1;
|
||||
mic.active = Math.max(0, mic.active - 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getTracks: () => [track],
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
};
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
public static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public state: 'inactive' | 'recording' = 'inactive';
|
||||
public mimeType: string;
|
||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
public onerror: ((event: unknown) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||
mic.lastRecorder = this;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('Not recording');
|
||||
}
|
||||
this.state = 'inactive';
|
||||
try {
|
||||
this.ondataavailable?.({
|
||||
data: blobFromBase64(base64Audio, mimeType),
|
||||
});
|
||||
} catch (err) {
|
||||
this.onerror?.(err);
|
||||
}
|
||||
this.dispatchEvent(new Event('stop'));
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
async function expectComposerReady(page: Page) {
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
||||
}
|
||||
|
||||
async function expectDictationStarted(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
async function expectDictationStopped(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await openSettings(page);
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStopped(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
if (mic.lastRecorder) {
|
||||
mic.lastRecorder.state = 'inactive';
|
||||
}
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expectComposerReady(page);
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
||||
);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
const toggle = page
|
||||
.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
})
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page
|
||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
||||
.first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
baseURL: async ({}, use) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
|
||||
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||
}
|
||||
await use(`http://localhost:${metroPort}`);
|
||||
},
|
||||
@@ -18,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
|
||||
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === '6767') {
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
|
||||
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,17 +39,17 @@ test.beforeEach(async ({ page }) => {
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
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 ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on('console', (message) => {
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
@@ -56,40 +57,21 @@ test.beforeEach(async ({ page }) => {
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = {
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const createAgentPreferences = {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
@@ -98,15 +80,15 @@ test.beforeEach(async ({ page }) => {
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach('browser-console', {
|
||||
body: entries.join('\n'),
|
||||
contentType: 'text/plain',
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(repo.path, 'README.md');
|
||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
||||
await appendFile(readmePath, `\n${lines}`);
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
||||
await fileToggle.click();
|
||||
|
||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
||||
await expect(scroll).toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
||||
}).toBe(true);
|
||||
|
||||
await scroll.hover();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel(0, 700);
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(fileToggle).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
||||
const prompt = `global draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
||||
|
||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
||||
const url = new URL(rawUrl)
|
||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
||||
const workspaceToken = pathMatch?.[1] ?? null
|
||||
let agentId = pathMatch?.[2] ?? null
|
||||
|
||||
if (!agentId) {
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
if (openMatch?.[1]) {
|
||||
agentId = openMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return { workspaceToken, agentId }
|
||||
}
|
||||
|
||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
||||
const url = new URL(rawUrl)
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
return openMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
await thinkingTrigger.click({ force: true })
|
||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
|
||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
||||
for (const label of preferred) {
|
||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true })
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = menu.getByRole('button')
|
||||
const count = await options.count()
|
||||
if (count > 0) {
|
||||
await options.last().click({ force: true })
|
||||
}
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
||||
const count = await candidates.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
||||
if (await isAnyStopVisible(page)) {
|
||||
return true
|
||||
}
|
||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
||||
const count = await interruptSendButton.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
||||
const locator = page.getByText(matcher).first()
|
||||
const matches = page.getByText(matcher)
|
||||
const count = await matches.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return await locator.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(360_000)
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
||||
const id = `${Date.now()}`
|
||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
||||
const seedToken = `ACK_SEED_${id}`
|
||||
const secondToken = `ACK_SECOND_${id}`
|
||||
expect(seedPrompt).not.toBe(secondPrompt)
|
||||
expect(seedToken).not.toBe(secondToken)
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 1) Seed workspace with an existing agent.
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await seedComposer.press('Enter')
|
||||
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const seededAgentId = seededContext.agentId
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 2) Use global New Agent entry and 3) select same workspace.
|
||||
await page.getByText('New agent', { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
await preferSlowerThinkingOption(page)
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
|
||||
// 4) Create second agent from global draft.
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(secondPrompt)
|
||||
await composer.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
if (page.url().includes('/new-agent')) {
|
||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
||||
await sendButton.click({ force: true })
|
||||
}
|
||||
|
||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
||||
if (!createdContext.agentId) {
|
||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const createdAgentId = createdContext.agentId
|
||||
expect(createdAgentId).toBeTruthy()
|
||||
expect(createdAgentId).not.toBe(seededAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
||||
|
||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
||||
return finalTabIds.includes(createdTabTestId)
|
||||
})
|
||||
.toBe(true)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
|
||||
// 6) Response assertions are scoped to created-agent active context.
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await lifecycleComposer.fill(lifecyclePrompt)
|
||||
await lifecycleComposer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
let sawRunning = false
|
||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
||||
if (await isRunningControlVisible(page)) {
|
||||
sawRunning = true
|
||||
break
|
||||
}
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
expect(sawRunning).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
type WaitForServerOptions = {
|
||||
host?: string;
|
||||
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to acquire port')));
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join('\n');
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const {
|
||||
host = '127.0.0.1',
|
||||
timeoutMs = 15000,
|
||||
label,
|
||||
childProcess,
|
||||
getRecentOutput,
|
||||
} = options;
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
}
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
||||
lastConnectionError instanceof Error
|
||||
? ` Last connection error: ${lastConnectionError.message}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
child.once('exit', () => {
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return 'empty response body';
|
||||
return "empty response body";
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
||||
method: 'GET',
|
||||
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -196,28 +192,42 @@ type OfferPayload = {
|
||||
};
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
|
||||
if (existsSync(relayDistEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const marker = "#offer=";
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||
}
|
||||
const encoded = url.slice(idx + marker.length);
|
||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
||||
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
}
|
||||
@@ -225,13 +235,17 @@ export default async function globalSetup() {
|
||||
const port = await getAvailablePort();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const cleanup = async () => {
|
||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
@@ -242,24 +256,30 @@ export default async function globalSetup() {
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
".paseo",
|
||||
"models",
|
||||
"local-speech",
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
@@ -271,18 +291,21 @@ export default async function globalSetup() {
|
||||
let relayReadyForSelectedPort = false;
|
||||
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stdout] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
@@ -297,8 +320,11 @@ export default async function globalSetup() {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stderr] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
@@ -316,7 +342,7 @@ export default async function globalSetup() {
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: 'Relay dev server',
|
||||
label: "Relay dev server",
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
@@ -339,15 +365,15 @@ export default async function globalSetup() {
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,40 +392,46 @@ export default async function globalSetup() {
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
const appDir = path.resolve(__dirname, "..");
|
||||
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
BROWSER: "none", // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
@@ -407,33 +439,33 @@ export default async function globalSetup() {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: '1',
|
||||
PASEO_VOICE_MODE_ENABLED: '1',
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_VOICE_MODE_ENABLED: "1",
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: 'development',
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
let stdoutBuffer = "";
|
||||
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
||||
stdoutBuffer += data.toString("utf8");
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
@@ -442,13 +474,13 @@ export default async function globalSetup() {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
@@ -462,8 +494,11 @@ export default async function globalSetup() {
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
@@ -473,12 +508,12 @@ export default async function globalSetup() {
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: 'Paseo daemon',
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: 'Metro web server',
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
@@ -489,11 +524,11 @@ export default async function globalSetup() {
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
@@ -502,11 +537,13 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
console.log("[e2e] Test daemon stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
|
||||
281
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
281
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
export type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
export type SeededAgent = {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
};
|
||||
|
||||
export type DaemonClientInstance = {
|
||||
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 }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isEditable =
|
||||
tagName === "textarea" ||
|
||||
tagName === "input" ||
|
||||
element.getAttribute("contenteditable") === "true";
|
||||
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||
});
|
||||
const scrollElement =
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
|
||||
)[0] ?? (root as HTMLElement);
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
): Promise<ScrollMetrics> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === '6767') {
|
||||
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
@@ -20,67 +23,58 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as any;
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, { expectedEndpoint, expectedServerId });
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint, expectedServerId }) => {
|
||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as any;
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (
|
||||
connections.some(
|
||||
(c: any) =>
|
||||
c?.type === "directTcp" &&
|
||||
typeof c?.endpoint === "string" &&
|
||||
/:6767\b/.test(c.endpoint),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return !connections.some(
|
||||
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ expectedEndpoint, expectedServerId },
|
||||
);
|
||||
|
||||
if (!needsReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem(
|
||||
'@paseo:daemon-registry',
|
||||
JSON.stringify([
|
||||
{
|
||||
serverId: expectedServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${expectedEndpoint}`,
|
||||
type: 'direct',
|
||||
endpoint: expectedEndpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
])
|
||||
);
|
||||
localStorage.setItem(
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
({ daemon, preferences }) => {
|
||||
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");
|
||||
},
|
||||
{ expectedEndpoint, nowIso, expectedServerId }
|
||||
{ daemon, preferences },
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
@@ -91,77 +85,116 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
return { registryRaw, prefsRaw };
|
||||
});
|
||||
|
||||
if (!snapshot.registryRaw) {
|
||||
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
let registry: any;
|
||||
try {
|
||||
registry = JSON.parse(snapshot.registryRaw);
|
||||
} catch {
|
||||
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const daemon = registry[0];
|
||||
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
|
||||
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
|
||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
if (daemon.serverId !== expectedServerId) {
|
||||
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
||||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
if (
|
||||
Array.isArray(connections) &&
|
||||
connections.some(
|
||||
(c: any) =>
|
||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
try {
|
||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||
if (prefs?.serverId !== daemon.serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
||||
await newAgentButton.click();
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
if (
|
||||
!(await composer
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
@@ -169,7 +202,7 @@ export const gotoHome = async (page: Page) => {
|
||||
export const openSettings = async (page: Page) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||
@@ -185,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
||||
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
.first();
|
||||
const bottomSheetHandle = page
|
||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
||||
.first();
|
||||
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||
return;
|
||||
}
|
||||
await bottomSheetBackdrop.click({ force: true });
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
if (await bottomSheetBackdrop.isVisible()) {
|
||||
@@ -221,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||
return;
|
||||
}
|
||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
||||
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||
if (await attachToggle.isVisible()) {
|
||||
await attachToggle.click({ force: true });
|
||||
await page.waitForTimeout(200);
|
||||
@@ -242,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
}
|
||||
await expect
|
||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||
const activeInput =
|
||||
(await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
|
||||
await activeInput.fill(trimmedDirectory);
|
||||
|
||||
if (activeInput === directorySearchInput) {
|
||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||
const plainOption = page
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||
.first();
|
||||
const prefixedUseOption = page
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||
.first();
|
||||
|
||||
if (await plainOption.isVisible().catch(() => false)) {
|
||||
@@ -270,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await prefixedUseOption.click({ force: true });
|
||||
} else {
|
||||
// Fallback: accept highlighted option (directory suggestion).
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
} else {
|
||||
// Legacy path picker fallback.
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for picker to close.
|
||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||
if (trimmedDirectory.startsWith('/var/')) {
|
||||
if (trimmedDirectory.startsWith("/var/")) {
|
||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||
}
|
||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
||||
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||
}
|
||||
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
||||
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 30000 }).toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
@@ -314,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
}
|
||||
|
||||
const fix = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||
const registry = JSON.parse(registryRaw) as any[];
|
||||
const prefs = JSON.parse(prefsRaw) as any;
|
||||
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
|
||||
if (!Array.isArray(registry) || registry.length !== 1)
|
||||
return { ok: false, reason: "registry shape" } as const;
|
||||
const serverId = registry[0]?.serverId;
|
||||
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
|
||||
if (typeof serverId !== "string" || serverId.length === 0)
|
||||
return { ok: false, reason: "missing serverId" } as const;
|
||||
prefs.serverId = serverId;
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
@@ -338,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
if (await input.isEditable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
||||
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||
const localhostOption = page.getByText('localhost', { exact: true }).first();
|
||||
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
.first();
|
||||
|
||||
if (await localhostOption.isVisible()) {
|
||||
await localhostOption.click();
|
||||
@@ -365,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await input.press("Enter");
|
||||
|
||||
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||
// so assert the user-visible result instead of forcing one route shape here.
|
||||
@@ -381,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page
|
||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
||||
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
@@ -403,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
||||
const menu = page.getByTestId("agent-thinking-menu").first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
||||
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||
let selected = false;
|
||||
for (const label of preferredLabels) {
|
||||
const option = menu
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
||||
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
@@ -422,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const options = menu.getByRole('button');
|
||||
const options = menu.getByRole("button");
|
||||
const count = await options.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const option = options.nth(index);
|
||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
||||
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
@@ -440,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,15 +495,17 @@ export interface AgentConfig {
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error('Provider must be a non-empty string.');
|
||||
throw new Error("Provider must be a non-empty string.");
|
||||
}
|
||||
|
||||
const providerTrigger = page
|
||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
@@ -477,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole('dialog').last();
|
||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first();
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
@@ -498,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
throw new Error('Model must be a non-empty string.');
|
||||
throw new Error("Model must be a non-empty string.");
|
||||
}
|
||||
|
||||
const modelTrigger = page
|
||||
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
@@ -517,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const dialog = page.getByRole("dialog");
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first();
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
@@ -539,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press('Enter');
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeTrigger = page
|
||||
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||
await modeTrigger.click();
|
||||
} else {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to filter modes
|
||||
await searchInput.fill(mode);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
||||
.first();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click({ force: true });
|
||||
|
||||
@@ -601,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
||||
|
||||
export const createAgentInRepo = async (
|
||||
page: Page,
|
||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
||||
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||
) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
@@ -610,25 +649,25 @@ export const createAgentInRepo = async (
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId('permission-request-question').first();
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId('permission-request-accept').first();
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId('permission-request-deny').first();
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
// First, let's debug what's happening - wait a bit to see the state
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -645,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||
|
||||
// Wait for the tool call result to appear
|
||||
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
await expect(toolCallResult)
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
.catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
|
||||
// Now wait for the stop button to disappear
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
|
||||
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeededHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const connection = buildDirectTcpConnection(input.endpoint);
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? TEST_HOST_LABEL,
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {}
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
): Promise<string[][]> {
|
||||
const durationMs = options.durationMs ?? 2_500;
|
||||
const intervalMs = options.intervalMs ?? 50;
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
||||
import { gotoHome } from './app';
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
}
|
||||
|
||||
export function workspaceLabelFromPath(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, '');
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith('/var/')) {
|
||||
if (trimmed.startsWith("/var/")) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
if (trimmed.startsWith('/private/var/')) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ''));
|
||||
if (trimmed.startsWith("/private/var/")) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||
);
|
||||
return page.locator(ids.join(',')).first();
|
||||
return page.locator(ids.join(",")).first();
|
||||
}
|
||||
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string }
|
||||
input: { title: string; subtitle: string },
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId('workspace-header-title');
|
||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
||||
const titleLocator = page.getByTestId("workspace-header-title");
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
|
||||
}
|
||||
|
||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(marker);
|
||||
await input.press('Enter');
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type TempRepo = {
|
||||
path: string;
|
||||
@@ -9,29 +9,29 @@ type TempRepo = {
|
||||
};
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
prefix = "paseo-e2e-",
|
||||
options?: { withRemote?: boolean },
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
|
||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, 'remote.git');
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const seededServerId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!seededServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const extraPort = Number(daemonPort) + 1;
|
||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const extraDaemon = {
|
||||
serverId: 'srv_e2e_extra_daemon',
|
||||
label: 'extra',
|
||||
connections: [
|
||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
||||
],
|
||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seededTestDaemon = {
|
||||
serverId: seededServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||
await page.addInitScript(
|
||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||
if (localStorage.getItem(seedOnceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
let parsed: any[] = [];
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
localStorage.setItem(seedOnceKey, '1');
|
||||
|
||||
const next = [...list];
|
||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
||||
if (!hasSeeded) {
|
||||
next.push(seededTestDaemon);
|
||||
}
|
||||
|
||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
||||
if (!alreadyPresent) {
|
||||
next.push(daemon);
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||
},
|
||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
extraDaemon.serverId,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||
|
||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await gotoHome(page);
|
||||
|
||||
// The selected host should be restored after a full reload without manual selection.
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Ensure this test's storage is deterministic even under parallel load.
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
||||
await newAgentButton.click();
|
||||
} else {
|
||||
await page.getByText('New agent', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await gotoHome(page);
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
return (
|
||||
entry?.serverId === serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.some(
|
||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ port: daemonPort, serverId },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
function encodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
const relayId = `relay:${expected.relay.endpoint}`;
|
||||
return (
|
||||
entry?.serverId === expected.serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.length === 1 &&
|
||||
entry.connections[0]?.id === relayId &&
|
||||
entry.connections[0]?.type === 'relay' &&
|
||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: offer },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await input.focus();
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
pasted: false,
|
||||
elementTag: active ? active.tagName : null,
|
||||
defaultPrevented: false,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
active.dispatchEvent(event);
|
||||
|
||||
return {
|
||||
pasted: true,
|
||||
elementTag: active.tagName,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.pasted).toBe(true);
|
||||
expect(result.defaultPrevented).toBe(true);
|
||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgentWithConfig,
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
function buildWriteCommand(filePath: string): string {
|
||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
||||
}
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
await expect
|
||||
.poll(() => existsSync(filePath), {
|
||||
message: `File ${filePath} should exist after allowing permission`,
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// After allowing, the file should be created successfully
|
||||
// The tool call count might still be 1 if the UI updates quickly
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('deny permission does not create the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
||||
|
||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
||||
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, nonExistentDir);
|
||||
|
||||
// Enter prompt text
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(promptText);
|
||||
|
||||
// Try to submit - this should fail with an error about the directory not existing
|
||||
await input.press('Enter');
|
||||
|
||||
// Verify error message is displayed (error includes the path)
|
||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
||||
|
||||
// Verify the prompt text is still preserved in the input
|
||||
await expect(input).toHaveValue(promptText);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
// Should eventually connect through the relay connection.
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Use relay by making the direct endpoint intentionally fail.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
||||
const page2 = await page.context().newPage();
|
||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
});
|
||||
await page2.goto('/');
|
||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
||||
await settingsButton2.click();
|
||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Stability window: keep both tabs open and ensure they remain online.
|
||||
await page.waitForTimeout(30_000);
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
const repoA = await createTempGitRepo();
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,238 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.ceil((p / 100) * sorted.length);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
avgMs: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: Math.min(...values),
|
||||
maxMs: Math.max(...values),
|
||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
||||
p95Ms: percentile(values, 95),
|
||||
p99Ms: percentile(values, 99),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStressCommand(doneMarker: string): string {
|
||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
||||
return [
|
||||
"i=1",
|
||||
"printf '\\033[?1049h\\033[2J'",
|
||||
"while [ $i -le 240 ]; do",
|
||||
"printf '\\033[H'",
|
||||
"r=1",
|
||||
"while [ $r -le 24 ]; do",
|
||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
||||
"r=$((r+1))",
|
||||
"done",
|
||||
"sleep 0.01",
|
||||
"i=$((i+1))",
|
||||
"done",
|
||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
const expected = currentlyExpanded ? "false" : "true";
|
||||
|
||||
const start = Date.now();
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
||||
await newTerminalButton.click();
|
||||
|
||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const monitor = {
|
||||
samples: [] as number[],
|
||||
active: true,
|
||||
rafId: 0,
|
||||
lastTs: performance.now(),
|
||||
};
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!monitor.active) {
|
||||
return;
|
||||
}
|
||||
monitor.samples.push(ts - monitor.lastTs);
|
||||
monitor.lastTs = ts;
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
|
||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
||||
{
|
||||
stop: () => {
|
||||
if (!monitor.active) {
|
||||
return { samples: monitor.samples };
|
||||
}
|
||||
monitor.active = false;
|
||||
cancelAnimationFrame(monitor.rafId);
|
||||
return { samples: monitor.samples };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
||||
const stressCommand = buildStressCommand(doneMarker);
|
||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const interactionLatenciesMs: number[] = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
||||
interactionLatenciesMs.push(latency);
|
||||
}
|
||||
|
||||
const rafResult = await page.evaluate(() => {
|
||||
const handle = (
|
||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
||||
).__PASEO_E2E_RAF_MONITOR__;
|
||||
if (!handle || typeof handle.stop !== "function") {
|
||||
return { samples: [] as number[] };
|
||||
}
|
||||
return handle.stop();
|
||||
});
|
||||
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const diagnostics = await page.evaluate(async () => {
|
||||
const debug = (
|
||||
window as {
|
||||
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
|
||||
consumeReports?: () => Promise<unknown[]>;
|
||||
};
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS_DEBUG__;
|
||||
if (!debug || typeof debug.consumeReports !== "function") {
|
||||
return { available: false, reports: [] as unknown[] };
|
||||
}
|
||||
try {
|
||||
const reports = await debug.consumeReports();
|
||||
return { available: true, reports: Array.isArray(reports) ? reports : [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: true,
|
||||
reports: [] as unknown[],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
const report = {
|
||||
mode: "report-only",
|
||||
profile: "single-stress",
|
||||
generatedAt: new Date().toISOString(),
|
||||
workload: {
|
||||
frames: 240,
|
||||
rowsPerFrame: 24,
|
||||
frameSleepMs: 10,
|
||||
doneMarker,
|
||||
postMarker,
|
||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
||||
},
|
||||
frameGapMs: {
|
||||
...summarize(frameGapsMs),
|
||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
diagnostics: {
|
||||
available: diagnostics.available,
|
||||
reportCount: diagnostics.reports.length,
|
||||
reports: diagnostics.reports,
|
||||
error: "error" in diagnostics ? diagnostics.error : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs'
|
||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
||||
const seedPrompt = `seed workspace ${Date.now()}`
|
||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
|
||||
// Force the setup onto the global draft surface. In slow runs the app can
|
||||
// still be on a previously opened agent/workspace view where the placement
|
||||
// form is not rendered.
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
||||
expect(workspaceRouteToken).toBeTruthy()
|
||||
await waitForWorkspaceTabsVisible(page)
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
||||
if (!draftTabTestId) {
|
||||
throw new Error('Expected a draft workspace tab to be created.')
|
||||
}
|
||||
|
||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(createPrompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page)
|
||||
expect(finalIds).toContain(draftTabTestId)
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
|
||||
const currentUrl = page.url()
|
||||
if (!workspaceRouteToken) {
|
||||
throw new Error('Expected workspace route token to be present.')
|
||||
}
|
||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
sampleWorkspaceTabIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function expectComposerFocused(page: Page) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await composer.evaluate(
|
||||
(element) => document.activeElement === element
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
||||
expect(draftTabTestId).toBeTruthy();
|
||||
|
||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill(createPrompt);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const snapshots = await samplingPromise;
|
||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page);
|
||||
expect(finalIds.length).toBe(withDraftIds.length);
|
||||
expect(finalIds).toContain(draftTabTestId!);
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
||||
(id) => !beforeSecondAgentIds.includes(id)
|
||||
);
|
||||
if (!secondAgentTabTestId) {
|
||||
throw new Error("Expected second agent tab to be created.");
|
||||
}
|
||||
|
||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
||||
if (!firstAgentTabTestId) {
|
||||
throw new Error("Expected first agent tab to exist.");
|
||||
}
|
||||
|
||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: workspacePath,
|
||||
prompt: `workspace header restore ${Date.now()}`,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
const initialTabCount = await workspaceTabs.count();
|
||||
const targetTabCount = initialTabCount + 8;
|
||||
|
||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
||||
await terminalButton.click();
|
||||
await expect
|
||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(attempt + 1);
|
||||
}
|
||||
|
||||
const agentBoundsBefore = await agentButton.boundingBox();
|
||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
||||
await tabsScroll.evaluate((el) => {
|
||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const agentBoundsAfter = await agentButton.boundingBox();
|
||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
||||
|
||||
expect(agentBoundsAfter).not.toBeNull();
|
||||
expect(terminalBoundsAfter).not.toBeNull();
|
||||
|
||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (initiallyExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { test } from './fixtures';
|
||||
import { setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from './helpers/workspace-ui';
|
||||
|
||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
||||
|
||||
const tokenA = `SYNC_A_${Date.now()}`;
|
||||
const tokenB = `SYNC_B_${Date.now()}`;
|
||||
|
||||
try {
|
||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await seedWorkspaceActivity(page, tokenA);
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoB.path);
|
||||
await seedWorkspaceActivity(page, tokenB);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-b-branch',
|
||||
subtitle: workspaceLabelFromPath(repoB.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
await repoB.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
import "expo-router/entry";
|
||||
|
||||
@@ -4,6 +4,8 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
@@ -13,8 +15,21 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
config.transformer.asyncRequireModulePath = require.resolve(
|
||||
"@expo/metro-config/build/async-require"
|
||||
const escapedAppSrcRoot = appSrcRoot
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
.join("[\\\\/]");
|
||||
const pathSeparatorPattern = "[\\\\/]";
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
...(config.resolver.extraNodeModules ?? {}),
|
||||
react: path.join(appNodeModulesRoot, "react"),
|
||||
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
|
||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||
};
|
||||
config.resolver.blockList = new RegExp(
|
||||
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
|
||||
);
|
||||
|
||||
function isLocalModuleImport(moduleName) {
|
||||
@@ -1,48 +1,41 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.32",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "expo export --platform web",
|
||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.18",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.32",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/server": "0.1.32",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -50,9 +43,14 @@
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/addon-ligatures": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
@@ -81,11 +79,10 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
@@ -93,7 +90,7 @@
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "^0.30.0",
|
||||
"react-native-nitro-modules": "0.33.8",
|
||||
"react-native-permissions": "^5.4.2",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
||||
// This allows multiple test runs in parallel across different worktrees
|
||||
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
testDir: "./e2e",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
@@ -16,17 +17,17 @@ export default defineConfig({
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'Desktop Chrome',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
name: "Desktop Chrome",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ??
|
||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
|
||||
29
packages/app/playwright.webkit.tmp.config.ts
Normal file
29
packages/app/playwright.webkit.tmp.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Safari",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
54
packages/app/public/index.html
Normal file
54
packages/app/public/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="%LANG_ISO_CODE%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[role='button'],
|
||||
[role='link'],
|
||||
[role='textbox'],
|
||||
[role='combobox'],
|
||||
[role='tab'],
|
||||
[role='switch'],
|
||||
[role='checkbox'],
|
||||
[role='slider'],
|
||||
[role='menuitem'],
|
||||
[tabindex],
|
||||
[contenteditable='true'] {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
@@ -108,5 +108,5 @@ rl.question(
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
36
packages/app/src/app-support/index-startup.ts
Normal file
36
packages/app/src/app-support/index-startup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export function shouldWaitOnStartupRace(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
pathname: string;
|
||||
}): boolean {
|
||||
if (input.onlineServerId) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname === WELCOME_ROUTE) {
|
||||
return false;
|
||||
}
|
||||
if (input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
|
||||
export function shouldRedirectToWelcome(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
pathname: string;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
}): boolean {
|
||||
if (input.onlineServerId || !input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname !== "/" && input.pathname !== "") {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
// Ensure Unistyles runs before Expo Router statically renders each page.
|
||||
import "../styles/unistyles";
|
||||
|
||||
const webEcosystemStyles = /* css */ `
|
||||
html {
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
body * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"],
|
||||
[data-testid="agent-chat-scroll"],
|
||||
[data-testid="git-diff-scroll"],
|
||||
[data-testid="file-explorer-tree-scroll"] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
function WebRespectfulStyleReset() {
|
||||
return (
|
||||
<style
|
||||
id="paseo-web-ecosystem"
|
||||
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
|
||||
/>
|
||||
{/* Reset scroll styles so React Native Web views behave like native. */}
|
||||
<ScrollViewStyleReset />
|
||||
<WebRespectfulStyleReset />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import "@/styles/unistyles";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
@@ -12,15 +18,30 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, ActivityIndicator, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHosts,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||
@@ -32,8 +53,9 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -42,27 +64,19 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
polyfillCrypto();
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logLeftSidebarOpenGesture(
|
||||
event: string,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
|
||||
}
|
||||
const HostRuntimeBootstrapContext = createContext(false);
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
@@ -70,32 +84,52 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (getTauri()) {
|
||||
void ensureOsNotificationPermission().then((granted) => {
|
||||
console.log(
|
||||
"[OSNotifications][Tauri] Startup permission preflight result:",
|
||||
granted ? "granted" : "not-granted"
|
||||
);
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"data" in payload &&
|
||||
typeof (payload as { data?: unknown }).data === "object" &&
|
||||
(payload as { data?: unknown }).data !== null
|
||||
? (payload as { data: Record<string, unknown> }).data
|
||||
: undefined;
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
},
|
||||
);
|
||||
|
||||
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||
if (typeof unlisten !== "function") {
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
removeDesktopNotificationListener = unlisten;
|
||||
});
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
||||
event.preventDefault();
|
||||
router.push(route as any);
|
||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
cancelled = true;
|
||||
removeDesktopNotificationListener?.();
|
||||
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,8 +157,7 @@ function PushNotificationRouter() {
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
};
|
||||
|
||||
const subscription =
|
||||
Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||
|
||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) {
|
||||
@@ -140,35 +173,103 @@ function PushNotificationRouter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const client = useHostRuntimeClient(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HostSessionManager() {
|
||||
const hosts = useHosts();
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hosts.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
||||
{children}
|
||||
</HostRuntimeBootstrapContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useStoreReady(): boolean {
|
||||
return useContext(HostRuntimeBootstrapContext);
|
||||
}
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||
const flexStyle = { flex: 1 } as const;
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
function AppContainer({
|
||||
children,
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const daemons = useHosts();
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const chromeEnabled = daemons.length > 0;
|
||||
const isOpen = chromeEnabled
|
||||
? isMobile
|
||||
? mobileView === "agent-list"
|
||||
: desktopAgentListOpen
|
||||
: false;
|
||||
const openGestureEnabled =
|
||||
chromeEnabled && isMobile && mobileView === "agent";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
@@ -176,27 +277,57 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
});
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
|
||||
// Track initial touch position for manual activation
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
children,
|
||||
chromeEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
|
||||
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
@@ -210,46 +341,31 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
|
||||
// If horizontal scroll is scrolled right, fail so ScrollView handles it
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate after 15px rightward movement
|
||||
if (deltaX > 15) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
runOnJS(logLeftSidebarOpenGesture)("start", {
|
||||
mobileView,
|
||||
openGestureEnabled,
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Start from closed position (-windowWidth) and move towards 0
|
||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
// Open if dragged more than 1/3 of sidebar or fast swipe
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
runOnJS(logLeftSidebarOpenGesture)("end", {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldOpen,
|
||||
mobileView,
|
||||
openGestureEnabled,
|
||||
});
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(openAgentList)();
|
||||
@@ -268,43 +384,24 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
openAgentList,
|
||||
mobileView,
|
||||
isGesturing,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
{content}
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
const storeReady = useStoreReady();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const isLoading = settingsLoading || !storeReady;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
@@ -323,7 +420,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
);
|
||||
@@ -354,7 +453,9 @@ function OfferLinkListener({
|
||||
});
|
||||
};
|
||||
|
||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
||||
void Linking.getInitialURL()
|
||||
.then(handleUrl)
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleUrl(event.url);
|
||||
@@ -370,9 +471,22 @@ function OfferLinkListener({
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
const hosts = useHosts();
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const shouldShowAppChrome = activeServerId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId || hosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -391,10 +505,40 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
}, [params.open, pathname]);
|
||||
|
||||
return (
|
||||
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
|
||||
<AppContainer
|
||||
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
||||
chromeEnabled={shouldShowAppChrome}
|
||||
>
|
||||
{children}
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FaviconStatusSync() {
|
||||
useFaviconStatus();
|
||||
return null;
|
||||
}
|
||||
|
||||
function NavigationActiveWorkspaceObserver() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoadingView({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<View
|
||||
@@ -448,52 +592,53 @@ function MissingDaemonView() {
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<PerfDiagnosticsProvider scope="root_layout">
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<QueryProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<PushNotificationRouter />
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</DaemonRegistryProvider>
|
||||
</QueryProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
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>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</PerfDiagnosticsProvider>
|
||||
</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const router = useRouter();
|
||||
@@ -16,7 +14,8 @@ export default function HostAgentReadyRoute() {
|
||||
const redirectedRef = useRef(false);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentCwd = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
@@ -38,7 +37,11 @@ export default function HostAgentReadyRoute() {
|
||||
if (normalizedCwd) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: normalizedCwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
@@ -77,7 +80,13 @@ export default function HostAgentReadyRoute() {
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
redirectedRef.current = true;
|
||||
if (cwd) {
|
||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||
|
||||
@@ -15,9 +16,12 @@ export default function HostIndexRoute() {
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const sessionAgents = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||
const sessionAgents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents : undefined,
|
||||
);
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,42 +41,42 @@ export default function HostIndexRoute() {
|
||||
}
|
||||
|
||||
const visibleAgents = sessionAgents
|
||||
? Array.from(sessionAgents.values()).filter(
|
||||
(agent) => !agent.archivedAt
|
||||
)
|
||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||
: [];
|
||||
visibleAgents.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
|
||||
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];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
primaryAgent.cwd.trim(),
|
||||
primaryAgent.id
|
||||
) as any
|
||||
workspaceId: primaryAgent.cwd.trim(),
|
||||
target: { kind: "agent", agentId: primaryAgent.id },
|
||||
}) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredWorkingDir =
|
||||
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
|
||||
const workspaceId = preferredWorkingDir || ".";
|
||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
pathname,
|
||||
preferences.serverId,
|
||||
preferences.workingDir,
|
||||
preferencesLoading,
|
||||
router,
|
||||
serverId,
|
||||
sessionAgents,
|
||||
]);
|
||||
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
|
||||
export default function HostNewAgentRoute() {
|
||||
export default function HostOpenProjectRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
return <OpenProjectScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentsScreen } from "@/screens/agents-screen";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <AgentsScreen serverId={serverId} />;
|
||||
return <SessionsScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -1,25 +1,89 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||
if (openIntent.kind === "agent") {
|
||||
return { kind: "agent", agentId: openIntent.agentId };
|
||||
}
|
||||
if (openIntent.kind === "terminal") {
|
||||
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||
}
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const expoPathname = usePathname()
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
||||
const serverId = activeRoute?.serverId ?? ''
|
||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
const router = useRouter();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
const route = openIntent
|
||||
? prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
})
|
||||
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
router.replace(route as any);
|
||||
}, [openValue, router, serverId, workspaceId]);
|
||||
|
||||
if (openValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
openIntent={openIntent}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,112 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
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 { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from "@/app-support/index-startup";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
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;
|
||||
},
|
||||
() => {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const requestedServerId = useMemo(() => {
|
||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
}, [params.serverId]);
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (requestedServerId) {
|
||||
const requested = daemons.find(
|
||||
(daemon) => daemon.serverId === requestedServerId
|
||||
);
|
||||
if (requested) {
|
||||
return requested.serverId;
|
||||
}
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId, requestedServerId]);
|
||||
const daemons = useHosts();
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setHasTimedOut(true);
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
if (!onlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(targetServerId) as any);
|
||||
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
||||
}, [onlineServerId, pathname, router]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRedirectToWelcome({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
pathname,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
router.replace(WELCOME_ROUTE as any);
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -5,16 +5,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult } from "expo-camera";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
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 { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildHostSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -148,23 +145,30 @@ export default function PairScanScreen() {
|
||||
targetServerId?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
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 { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost]
|
||||
)
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
const returnToSource = useCallback(
|
||||
@@ -189,7 +193,7 @@ export default function PairScanScreen() {
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
}
|
||||
},
|
||||
[router, source, sourceServerId, targetServerId]
|
||||
[router, source, sourceServerId, targetServerId],
|
||||
);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
@@ -237,11 +241,14 @@ export default function PairScanScreen() {
|
||||
|
||||
if (targetServerId && offer.serverId !== targetServerId) {
|
||||
lastScannedRef.current = null;
|
||||
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
|
||||
Alert.alert(
|
||||
"Wrong daemon",
|
||||
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await probeConnection(
|
||||
const { client } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
@@ -250,6 +257,7 @@ export default function PairScanScreen() {
|
||||
},
|
||||
{ serverId: offer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||
@@ -268,7 +276,7 @@ export default function PairScanScreen() {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
@@ -311,7 +319,7 @@ export default function PairScanScreen() {
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
void updateHost(serverId, { label }).finally(() => {
|
||||
void renameHost(serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
});
|
||||
@@ -332,10 +340,7 @@ export default function PairScanScreen() {
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.permissionButton}
|
||||
onPress={() => void requestPermission()}
|
||||
>
|
||||
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -354,9 +359,7 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
<Text style={styles.helperText}>
|
||||
Point your camera at the pairing QR code.
|
||||
</Text>
|
||||
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||
{isPairing ? (
|
||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||
Pairing…
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const daemons = useHosts();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find(
|
||||
(daemon) => daemon.serverId === preferences.serverId
|
||||
);
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
@@ -29,16 +27,16 @@ export default function LegacySettingsRoute() {
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
if (preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
}, [preferencesLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
if (preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
5
packages/app/src/app/welcome.tsx
Normal file
5
packages/app/src/app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||
baseDirectoryName: string;
|
||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||
releasePreviewUrl?: (input: {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}) => Promise<void>;
|
||||
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||
}): AttachmentStore {
|
||||
const baseDirectory = FileSystem.cacheDirectory
|
||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||
idempotent: true,
|
||||
});
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
|
||||
}
|
||||
|
||||
export async function encodeAttachmentsForSend(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
attachments: readonly AttachmentMetadata[] | undefined,
|
||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = encoded.filter(
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null,
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
export async function resolveAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata
|
||||
): Promise<string> {
|
||||
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.resolvePreviewUrl({ attachment });
|
||||
}
|
||||
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
|
||||
}
|
||||
|
||||
export async function deleteAttachments(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
attachments: readonly AttachmentMetadata[] | undefined,
|
||||
): Promise<void> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
@@ -113,7 +111,7 @@ export async function deleteAttachments(
|
||||
error,
|
||||
});
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
packages/app/src/attachments/store.test.ts
Normal file
14
packages/app/src/attachments/store.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
|
||||
|
||||
describe("attachment store", () => {
|
||||
afterEach(() => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("creates the default web attachment store without runtime module resolution errors", async () => {
|
||||
const store = await getAttachmentStore();
|
||||
|
||||
expect(store.storageType).toBe("web-indexeddb");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,23 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = require(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
if (isDesktop()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = require(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
);
|
||||
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = require(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
);
|
||||
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
|
||||
|
||||
export function useAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata | null | undefined
|
||||
attachment: AttachmentMetadata | null | undefined,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
|
||||
url: currentUrl,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
attachment?.id,
|
||||
attachment?.storageType,
|
||||
attachment?.storageKey,
|
||||
attachment?.mimeType,
|
||||
]);
|
||||
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
|
||||
function runTx<T>(
|
||||
db: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
const source = input.source;
|
||||
if (source.kind === "blob") {
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
||||
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
|
||||
const blob =
|
||||
source.blob.type === mimeType
|
||||
? source.blob
|
||||
: source.blob.slice(0, source.blob.size, mimeType);
|
||||
return { blob, mimeType };
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
|
||||
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
|
||||
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
|
||||
store.get(id)
|
||||
store.get(id),
|
||||
);
|
||||
if (!record?.blob) {
|
||||
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
|
||||
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
||||
|
||||
try {
|
||||
await runTx(db, "readwrite", (store) =>
|
||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
|
||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
||||
const cursorRequest = store.openCursor();
|
||||
|
||||
cursorRequest.onerror = () => {
|
||||
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
|
||||
reject(
|
||||
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
|
||||
);
|
||||
};
|
||||
|
||||
cursorRequest.onsuccess = () => {
|
||||
|
||||
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
scrollView: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
processItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
processText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: '500',
|
||||
fontWeight: "500",
|
||||
},
|
||||
processTextActive: {
|
||||
color: theme.colors.primaryForeground,
|
||||
@@ -147,20 +147,21 @@ export function ActiveProcesses({
|
||||
onPress={onSelectOrchestrator}
|
||||
style={({ pressed }) => [
|
||||
styles.processItem,
|
||||
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
|
||||
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.agentIcon} />
|
||||
<Text style={[
|
||||
styles.processText,
|
||||
viewMode === 'orchestrator' && styles.processTextActive,
|
||||
]}>Orchestrator</Text>
|
||||
<Text
|
||||
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
|
||||
>
|
||||
Orchestrator
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Agent pills */}
|
||||
{agents.map((agent) => {
|
||||
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
|
||||
const isActive = viewMode === "agent" && activeAgentId === agent.id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -174,15 +175,21 @@ export function ActiveProcesses({
|
||||
>
|
||||
<View style={styles.agentIcon} />
|
||||
|
||||
<Text style={[
|
||||
styles.processText,
|
||||
isActive && styles.processTextActive,
|
||||
]}>{agent.id.substring(0, 8)}</Text>
|
||||
<Text style={[styles.processText, isActive && styles.processTextActive]}>
|
||||
{agent.id.substring(0, 8)}
|
||||
</Text>
|
||||
|
||||
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
|
||||
/>
|
||||
|
||||
{agent.currentModeId && (
|
||||
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.modeIndicator,
|
||||
{ backgroundColor: getModeColor(agent.currentModeId) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
@@ -109,6 +101,7 @@ export interface AdaptiveModalSheetProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -118,11 +111,11 @@ export function AdaptiveModalSheet({
|
||||
onClose,
|
||||
children,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const dismissingForVisibilityRef = useRef(false);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -148,19 +141,14 @@ export function AdaptiveModalSheet({
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -173,6 +161,7 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
@@ -180,11 +169,7 @@ export function AdaptiveModalSheet({
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -209,11 +194,7 @@ export function AdaptiveModalSheet({
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -254,13 +235,12 @@ export function AdaptiveModalSheet({
|
||||
*/
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
if (isMobile) {
|
||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||
}
|
||||
|
||||
return <TextInput ref={ref} {...props} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,12 +60,21 @@ export function AddHostMethodModal({
|
||||
}, [onPasteLink]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
|
||||
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
|
||||
<AdaptiveModalSheet
|
||||
title="Add connection"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
>
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityLabel="Direct connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or Tailscale (unencrypted).</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -74,16 +83,20 @@ export function AddHostMethodModal({
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityLabel="Paste pairing link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user