mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
293 Commits
v0.1.94
...
dotfile-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 | ||
|
|
db03b1f3fd | ||
|
|
cb486c3a5a | ||
|
|
7dad7a377c | ||
|
|
e32c50f2d7 | ||
|
|
e3eb633582 | ||
|
|
beac4544fd | ||
|
|
c2271ff0ca | ||
|
|
f91806defe | ||
|
|
e9431517a0 | ||
|
|
4e28b0941b | ||
|
|
7c6152663e | ||
|
|
5fc53c576e | ||
|
|
8eb9640f28 | ||
|
|
0798f997c3 | ||
|
|
68a169b29d | ||
|
|
b571874852 | ||
|
|
ad803005c8 | ||
|
|
86775c18f3 | ||
|
|
cf2fccd01d | ||
|
|
811f52395e | ||
|
|
df36a958f8 | ||
|
|
111fdb81fd | ||
|
|
efd7ab3420 | ||
|
|
f94ec0e430 | ||
|
|
0614618d2f | ||
|
|
f31bc2b8b7 | ||
|
|
31d14ec8a7 | ||
|
|
a1afdfee51 | ||
|
|
b5559dc1de | ||
|
|
85acaceb16 | ||
|
|
e63a971968 | ||
|
|
b613bea9f6 | ||
|
|
57800a0f17 | ||
|
|
0d1c8db87d | ||
|
|
6a23bbd121 | ||
|
|
3288e1cfb9 | ||
|
|
cb212b4e5c | ||
|
|
4968969c87 | ||
|
|
96573d0bcc | ||
|
|
9b6aa99396 | ||
|
|
276c1f48f1 | ||
|
|
821d194fbe | ||
|
|
ab3ed56513 | ||
|
|
62bdb52eaa | ||
|
|
d7b24ab124 | ||
|
|
c7e27fdd5b | ||
|
|
fc52b2812c | ||
|
|
47a71631e8 | ||
|
|
696a9f0119 | ||
|
|
26a8a04651 | ||
|
|
a908384e5a | ||
|
|
24a1f1b8b9 | ||
|
|
65f1143e6a | ||
|
|
f275275074 | ||
|
|
5ef118ea33 | ||
|
|
8228bafc5d | ||
|
|
d6b946720e | ||
|
|
07680cdd69 | ||
|
|
1ce00dca1f | ||
|
|
28ea0914c5 | ||
|
|
6277ba1ff9 | ||
|
|
85322c5968 | ||
|
|
daf7042cd2 | ||
|
|
99643ad085 | ||
|
|
40265782d4 | ||
|
|
9ad49fca92 | ||
|
|
3fbd82664d | ||
|
|
14a91d889c | ||
|
|
2c9db5279c | ||
|
|
28c5e55bd9 | ||
|
|
862154541a | ||
|
|
8c3b709794 | ||
|
|
c5c2ace698 | ||
|
|
a49c658d1f | ||
|
|
1109e453bc | ||
|
|
4e2c06ec71 | ||
|
|
d4cdd4d749 | ||
|
|
3d6b4adc68 | ||
|
|
bbde200aa2 | ||
|
|
a19e3305ad | ||
|
|
4ba0ce44e5 | ||
|
|
d5cb4421b5 | ||
|
|
26f169866f | ||
|
|
bdd9419189 | ||
|
|
f12c9e9cfa | ||
|
|
2692211ef9 | ||
|
|
f13c496ebc | ||
|
|
2cf041c4d8 | ||
|
|
c00121273b | ||
|
|
3b5e34fe12 | ||
|
|
9960772fe9 | ||
|
|
eaecd8d0a7 | ||
|
|
53e39e6d23 | ||
|
|
ecf4f9037e | ||
|
|
f9ff668a71 | ||
|
|
b561b108a7 | ||
|
|
0be5cc9dae | ||
|
|
e4f32a4d82 | ||
|
|
b625b69302 | ||
|
|
1b9543023e | ||
|
|
83123987d7 | ||
|
|
45a7a91768 | ||
|
|
484ffc4334 | ||
|
|
507345dbee | ||
|
|
70ea960153 | ||
|
|
119afd7281 | ||
|
|
e58725ee39 | ||
|
|
36cdfaf516 | ||
|
|
c5442ef0a2 | ||
|
|
0748149ec9 | ||
|
|
8c67415fdb | ||
|
|
6fe320055d | ||
|
|
2ef119c24b | ||
|
|
79be6d8dba | ||
|
|
42e3f63dec | ||
|
|
7a817774b7 | ||
|
|
12101914c2 | ||
|
|
f94b488c4f | ||
|
|
9170c2f0e6 | ||
|
|
78d46a8a82 | ||
|
|
1970a14349 | ||
|
|
a397d411dd | ||
|
|
d9cd6ea0fd | ||
|
|
c03e7b82b4 | ||
|
|
26b2f25050 | ||
|
|
2b5cc727f0 | ||
|
|
18f880e561 | ||
|
|
cf4dd8616c | ||
|
|
617cf8a7bf | ||
|
|
9c86a410ea | ||
|
|
2d8acc1611 | ||
|
|
f2e7ac2dc1 | ||
|
|
fbd86564dd | ||
|
|
c38510d347 | ||
|
|
ab433aa110 | ||
|
|
ecb74bc8fb | ||
|
|
02838ca8bc | ||
|
|
a924059daf | ||
|
|
1927dbb190 | ||
|
|
4779757138 | ||
|
|
5180708e26 | ||
|
|
7f74853174 | ||
|
|
c2d7796b78 | ||
|
|
25252d1b86 | ||
|
|
27ecb3a7a9 | ||
|
|
fd2fed03a6 | ||
|
|
4b45bab7e3 | ||
|
|
b0e6dbceef | ||
|
|
3493e0492d | ||
|
|
8bd6c617a3 | ||
|
|
68bbc75df1 | ||
|
|
0718e7c914 | ||
|
|
b3661193af | ||
|
|
2c4e443ad7 | ||
|
|
6a3856b639 | ||
|
|
80fc11541f | ||
|
|
b8bf2345fd | ||
|
|
4354ad3e27 | ||
|
|
ba8fe261ee | ||
|
|
4534754617 | ||
|
|
d1481833e6 | ||
|
|
acea7f3d24 | ||
|
|
2b0740ff84 | ||
|
|
7af92120fe | ||
|
|
cda66ae5f3 | ||
|
|
f9660c7e89 | ||
|
|
e73b1c4724 | ||
|
|
ccb8714a71 | ||
|
|
b90baaff71 | ||
|
|
d0189f3f65 | ||
|
|
60cf566b79 | ||
|
|
de5307dfa0 | ||
|
|
0c6f008115 | ||
|
|
1fc565118b | ||
|
|
112f1ace65 | ||
|
|
ee3a7a2d28 | ||
|
|
aff5fe651c | ||
|
|
05e8d34613 | ||
|
|
5d0fbd1d37 | ||
|
|
9a09ccb3d6 | ||
|
|
8cc74fb75d | ||
|
|
01a2afa7c5 | ||
|
|
931ea97942 | ||
|
|
f876b80f7a | ||
|
|
4f78d4f679 | ||
|
|
2064e308a7 | ||
|
|
c3321ea8ff | ||
|
|
3d5059ab05 | ||
|
|
ef87b09146 | ||
|
|
d89affb5dc | ||
|
|
00c57617d5 | ||
|
|
b3f81c0bf4 | ||
|
|
31fcde2ea6 | ||
|
|
fe15b68e06 | ||
|
|
ea2f768d3f | ||
|
|
eb628765d7 | ||
|
|
61bfc5b631 | ||
|
|
59299ac635 | ||
|
|
df18bbc43a | ||
|
|
1250f5cf4f | ||
|
|
de84c8f179 | ||
|
|
7c1870e887 | ||
|
|
c8717e0c70 | ||
|
|
3163c7d09b | ||
|
|
4e899c644a | ||
|
|
afe7eb876c | ||
|
|
e4da3693e9 | ||
|
|
a51c39eddf | ||
|
|
b7ab1a1732 | ||
|
|
35c2e5de8e | ||
|
|
6c0b5c7640 | ||
|
|
127b138a91 | ||
|
|
6fc52d85cb | ||
|
|
608a950155 | ||
|
|
fc4c2aa367 | ||
|
|
2bc48993a5 | ||
|
|
e58de8c338 | ||
|
|
4a6503f177 | ||
|
|
90d386b88e | ||
|
|
0c9802b22b | ||
|
|
1340a80144 | ||
|
|
11abbcc630 | ||
|
|
14743fa0b0 | ||
|
|
a61118776a | ||
|
|
7e5d7f7c67 | ||
|
|
69741e0770 | ||
|
|
7b4b27d877 | ||
|
|
8c509017ff | ||
|
|
c32b80a2ed | ||
|
|
fcb7f8e215 | ||
|
|
e202ca5036 | ||
|
|
0fef66283a | ||
|
|
37de9ea796 | ||
|
|
3cf3a63423 | ||
|
|
10664487fb | ||
|
|
be3a1ea6c3 | ||
|
|
88b48c9d09 | ||
|
|
e77c8e2bf3 | ||
|
|
f5d39446cc | ||
|
|
64b2949f08 | ||
|
|
36a136d811 | ||
|
|
1812884ecd | ||
|
|
c464c2bcb4 | ||
|
|
9966e49329 | ||
|
|
e90f398ac0 | ||
|
|
ba9f7740a8 | ||
|
|
a4d4d42873 | ||
|
|
af0437e6df | ||
|
|
1c9e78616f | ||
|
|
754f6336c0 | ||
|
|
e84a3ff574 | ||
|
|
713a042dc5 | ||
|
|
42969e40d5 | ||
|
|
a4c4418164 | ||
|
|
71b5c35d9b | ||
|
|
eb94b70848 | ||
|
|
f4450cda51 | ||
|
|
43e17c1e7c | ||
|
|
a6ec352f07 | ||
|
|
0d76654ba0 | ||
|
|
21deb08673 | ||
|
|
f352072dac | ||
|
|
bf8f2510c8 | ||
|
|
72b67f48e3 | ||
|
|
442a1b9a5a | ||
|
|
ef750e0a85 | ||
|
|
a5c4ba4d89 | ||
|
|
f4687668e1 | ||
|
|
8ef75c0914 | ||
|
|
a9ea3c76a3 | ||
|
|
e0463ad17f | ||
|
|
04a985bf23 | ||
|
|
10a556d6a1 | ||
|
|
fbd3dca7ed | ||
|
|
5b321c0644 | ||
|
|
b0ca08a452 | ||
|
|
8cd51c8a47 | ||
|
|
2c27f21a90 | ||
|
|
a929ea9726 | ||
|
|
c4d544baf6 | ||
|
|
889d058658 | ||
|
|
85fa38bdde | ||
|
|
736b2da1a2 | ||
|
|
9c43e0222a | ||
|
|
4824cfd05b | ||
|
|
7e46f6a647 | ||
|
|
fb0c5b27f3 | ||
|
|
a3951495d4 | ||
|
|
00a6e636fb | ||
|
|
9af18ef2f5 | ||
|
|
fd0793dc02 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
@@ -8,4 +8,4 @@ user-invocable: true
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
|
||||
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.
|
||||
|
||||
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
.git
|
||||
.dev
|
||||
.playwright-mcp
|
||||
.paseo
|
||||
.tasks
|
||||
.wrangler
|
||||
**/.wrangler
|
||||
**/.tanstack
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/.cache
|
||||
**/.expo
|
||||
**/test-results
|
||||
**/*.tsbuildinfo
|
||||
artifacts
|
||||
packages/app/android
|
||||
packages/desktop/release
|
||||
plan.*.log
|
||||
*.log
|
||||
CLAUDE.local.md
|
||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -189,11 +189,23 @@ jobs:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install dependencies with retry
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
@@ -229,6 +241,11 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -240,11 +257,23 @@ jobs:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install dependencies with retry
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci; then
|
||||
exit 0
|
||||
else
|
||||
exit_code=$?
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
@@ -256,7 +285,7 @@ jobs:
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -264,7 +293,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
|
||||
12
.github/workflows/desktop-release.yml
vendored
12
.github/workflows/desktop-release.yml
vendored
@@ -329,6 +329,18 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Windows arm64 native search packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
|
||||
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
|
||||
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
|
||||
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
205
.github/workflows/docker.yml
vendored
Normal file
205
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".github/workflows/docker.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paseo_version:
|
||||
description: "Paseo version to build. Required when publish is true."
|
||||
required: false
|
||||
default: ""
|
||||
publish:
|
||||
description: "Publish the image to GHCR. Manual publishes require paseo_version."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
source_build:
|
||||
description: "Build from the checked-out source tree instead of npm."
|
||||
required: false
|
||||
default: "auto"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- "false"
|
||||
- "true"
|
||||
publish_latest:
|
||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image: ${{ steps.values.outputs.image }}
|
||||
install_version: ${{ steps.values.outputs.install_version }}
|
||||
publish: ${{ steps.values.outputs.publish }}
|
||||
source_build: ${{ steps.values.outputs.source_build }}
|
||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- id: values
|
||||
env:
|
||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image="ghcr.io/${owner}/paseo"
|
||||
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||
publish=false
|
||||
source_build=false
|
||||
publish_latest=false
|
||||
prerelease=false
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
install_version="${REF_NAME#v}"
|
||||
publish=true
|
||||
if [[ "${REF_NAME}" == *-* ]]; then
|
||||
prerelease=true
|
||||
source_build=true
|
||||
else
|
||||
publish_latest=true
|
||||
fi
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
|
||||
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
|
||||
echo "::error::paseo_version is required for manual Docker publishes."
|
||||
exit 1
|
||||
fi
|
||||
publish=true
|
||||
fi
|
||||
|
||||
if [[ "${install_version}" == *-* ]]; then
|
||||
prerelease=true
|
||||
fi
|
||||
|
||||
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||
true)
|
||||
source_build=true
|
||||
;;
|
||||
false)
|
||||
source_build=false
|
||||
;;
|
||||
auto)
|
||||
if [[ "${prerelease}" == "true" ]]; then
|
||||
source_build=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::source_build must be auto, true, or false."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||
publish_latest=true
|
||||
fi
|
||||
fi
|
||||
|
||||
check_tag="${image}:check-${GITHUB_SHA::12}"
|
||||
publish_tags="${image}:${install_version}"
|
||||
if [[ "${publish_latest}" == "true" ]]; then
|
||||
publish_tags="${publish_tags}"$'\n'"${image}:latest"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "image=${image}"
|
||||
echo "install_version=${install_version}"
|
||||
echo "publish=${publish}"
|
||||
echo "source_build=${source_build}"
|
||||
echo "check_tag=${check_tag}"
|
||||
echo "publish_tags<<EOF"
|
||||
echo "${publish_tags}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.check_tag }}
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
|
||||
publish:
|
||||
needs: setup
|
||||
if: needs.setup.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
build-args: |
|
||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||
tags: ${{ needs.setup.outputs.publish_tags }}
|
||||
push: true
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=paseo
|
||||
cache-to: type=gha,scope=paseo,mode=max
|
||||
196
CHANGELOG.md
196
CHANGELOG.md
@@ -1,11 +1,204 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
|
||||
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- Copy a troubleshooting report from Settings when support needs host, daemon, provider, and log details ([#1728](https://github.com/getpaseo/paseo/pull/1728))
|
||||
- Claude image tool results now render as images in chat ([#1717](https://github.com/getpaseo/paseo/pull/1717))
|
||||
- Added Japanese ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64))
|
||||
- Added Brazilian Portuguese ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio))
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider diagnostics stay useful even when model discovery is slow ([#1724](https://github.com/getpaseo/paseo/pull/1724))
|
||||
- Slow provider requests no longer make the app look disconnected ([#1723](https://github.com/getpaseo/paseo/pull/1723))
|
||||
- Worktrees linked to differently named tracked branches find their PRs correctly ([#1718](https://github.com/getpaseo/paseo/pull/1718))
|
||||
- Workspaces started from slash-command prompts get clearer names ([#1709](https://github.com/getpaseo/paseo/pull/1709))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi no longer creates empty sessions while loading new-agent options ([#1727](https://github.com/getpaseo/paseo/pull/1727))
|
||||
- Windows daemon status finds the daemon process more reliably ([#1725](https://github.com/getpaseo/paseo/pull/1725))
|
||||
- OpenAI voice credentials no longer affect other OpenAI-backed tools
|
||||
- Provider model lists no longer disappear during refresh
|
||||
|
||||
## 0.1.100 - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Cycle agent modes with Shift+Tab
|
||||
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
|
||||
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
|
||||
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
|
||||
|
||||
## 0.1.99 - 2026-06-23
|
||||
|
||||
### Improved
|
||||
|
||||
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
|
||||
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
|
||||
|
||||
### Fixed
|
||||
|
||||
- ACP providers like Grok no longer show duplicate user messages
|
||||
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
|
||||
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
|
||||
|
||||
## 0.1.98 - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
|
||||
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
|
||||
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
|
||||
- Add a project without creating a workspace
|
||||
- Add a setting to show branch names instead of titles in the sidebar
|
||||
|
||||
### Improved
|
||||
|
||||
- Mid-turn thinking and mode changes now say they apply next turn
|
||||
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
|
||||
- A running agent's mode change is remembered for new agents
|
||||
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
|
||||
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
|
||||
- Reopening an archived Codex agent no longer hangs
|
||||
- Claude's context meter no longer jumps to subagent usage
|
||||
- Claude's context meter fills from the first message in a new session
|
||||
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
|
||||
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
|
||||
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
|
||||
- Collapsed sidebar projects stay collapsed
|
||||
|
||||
## 0.1.97 - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Simplify workspace model** — run multiple workspaces on the same code without a worktree, each with its own agents, terminals, and status ([#1539](https://github.com/getpaseo/paseo/pull/1539))
|
||||
- **Reopen archived workspaces from History** — restore a past workspace even after its worktree was removed
|
||||
- **Terminals show when their agent is working, idle, or waiting for input** ([#1507](https://github.com/getpaseo/paseo/pull/1507))
|
||||
- **Attach files to agents on mobile** ([#1501](https://github.com/getpaseo/paseo/pull/1501))
|
||||
- **Hide dotfiles in the file explorer** ([#1516](https://github.com/getpaseo/paseo/pull/1516) by [@yuruiz](https://github.com/yuruiz))
|
||||
- **Pin terminal, browser, and new-tab buttons to the tab row and sidebar**
|
||||
- **Create a new workspace with a keyboard shortcut**
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace titles come from your first prompt and are shorter ([#1563](https://github.com/getpaseo/paseo/pull/1563))
|
||||
- Copy a workspace's branch or path from its hover card
|
||||
- Terminals stay smooth under heavy output ([#1500](https://github.com/getpaseo/paseo/pull/1500))
|
||||
- Worktrees are removed when their last workspace is archived ([#1562](https://github.com/getpaseo/paseo/pull/1562))
|
||||
- Finish notifications include subagent results ([#1558](https://github.com/getpaseo/paseo/pull/1558))
|
||||
- Cursor lists only models you can select ([#1556](https://github.com/getpaseo/paseo/pull/1556))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Brief daemon slowdowns no longer drop your connection
|
||||
- Linux AppImage updates no longer hang on quit or delete the app ([#1485](https://github.com/getpaseo/paseo/pull/1485) by [@xpufx](https://github.com/xpufx))
|
||||
- Opening Providers settings no longer crashes on Android ([#1537](https://github.com/getpaseo/paseo/pull/1537))
|
||||
- Coding-agent terminal shortcuts work on Windows ([#1509](https://github.com/getpaseo/paseo/pull/1509))
|
||||
- ACP and Kimi sessions can be imported again ([#1510](https://github.com/getpaseo/paseo/pull/1510) by [@wbxl2000](https://github.com/wbxl2000))
|
||||
- ACP agents shut down without leaving orphaned processes ([#1460](https://github.com/getpaseo/paseo/pull/1460) by [@yeshan333](https://github.com/yeshan333))
|
||||
- Imported session previews show clean prompts ([#1502](https://github.com/getpaseo/paseo/pull/1502))
|
||||
- Local pairing offers use the correct app URL ([#1187](https://github.com/getpaseo/paseo/pull/1187) by [@aibaiiqpl](https://github.com/aibaiiqpl))
|
||||
- The app no longer freezes from repeated provider re-probes
|
||||
- Removing a project from the sidebar now removes the project itself instead of leaving it behind
|
||||
- Workspace shortcut numbers no longer appear for the wrong key ([#1580](https://github.com/getpaseo/paseo/pull/1580) by [@cleiter](https://github.com/cleiter))
|
||||
- Chats no longer hang when a message contains unmatched backticks ([#1585](https://github.com/getpaseo/paseo/pull/1585) by [@thaning0](https://github.com/thaning0))
|
||||
|
||||
## 0.1.96 - 2026-06-13
|
||||
|
||||
_This release only fixes an Android issue — desktop users don't need to update._
|
||||
|
||||
### Fixed
|
||||
|
||||
- On Android, the sidebar no longer reappears and gets stuck after you open a chat
|
||||
|
||||
## 0.1.95 - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Attach any file to agents on desktop** ([#1474](https://github.com/getpaseo/paseo/pull/1474))
|
||||
|
||||
### Improved
|
||||
|
||||
- The git push button shows before merge actions when your branch is ahead ([#1488](https://github.com/getpaseo/paseo/pull/1488))
|
||||
- SVG attachments are uploaded to disk
|
||||
- Switching workspaces feels smoother
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed cases where outdated GitHub data could be shown ([#1491](https://github.com/getpaseo/paseo/pull/1491))
|
||||
- Uploaded images in PR comments and review threads now load in the PR panel ([#1486](https://github.com/getpaseo/paseo/pull/1486))
|
||||
- Opening a project whose folder is missing shows a clear error ([#1490](https://github.com/getpaseo/paseo/pull/1490))
|
||||
- The new workspace title moves out of the way of the keyboard ([#1489](https://github.com/getpaseo/paseo/pull/1489))
|
||||
- Sidebars no longer open on their own on Android
|
||||
|
||||
## 0.1.94 - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Attach pull request comments, reviews, threads, and failed check logs to chat from the PR panel** ([#1400](https://github.com/getpaseo/paseo/pull/1400))
|
||||
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1478](https://github.com/getpaseo/paseo/pull/1478) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1282](https://github.com/getpaseo/paseo/pull/1282), [#1478](https://github.com/getpaseo/paseo/pull/1478) by [@chyendongnhanh338](https://github.com/chyendongnhanh338), [@dwyanewang](https://github.com/dwyanewang))
|
||||
- **Create reusable terminal profiles from Host settings**
|
||||
- **Open workspaces in Antigravity** ([#1424](https://github.com/getpaseo/paseo/pull/1424) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
@@ -15,7 +208,6 @@
|
||||
- Copy file paths directly from file preview tab menus ([#1473](https://github.com/getpaseo/paseo/pull/1473))
|
||||
- PR status stays current after an agent merges a branch ([#1455](https://github.com/getpaseo/paseo/pull/1455))
|
||||
- Workspace tabs stay fast by retaining only the active workspace screens ([#1472](https://github.com/getpaseo/paseo/pull/1472))
|
||||
- iOS simulator previews can be launched from worktrees
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -33,17 +33,21 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
@@ -69,6 +73,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **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.
|
||||
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
Paseo is an opinionated product maintained by one person right now.
|
||||
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
|
||||
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
## Becoming a maintainer
|
||||
|
||||
Follow these rules if you want your PR to be merged:
|
||||
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
|
||||
|
||||
Here's the progression:
|
||||
|
||||
1. Get involved in the community: answer questions in Discord and on GitHub
|
||||
2. Triage bugs: replicate and help fix them
|
||||
3. Work on maintainer-approved features
|
||||
|
||||
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
|
||||
|
||||
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
|
||||
|
||||
## Pull requests
|
||||
|
||||
✅ Will be accepted
|
||||
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
@@ -18,15 +32,25 @@ Follow these rules if you want your PR to be merged:
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
Your PR will be closed if you do any of these:
|
||||
⛔️ Will be rejected
|
||||
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Add a feature or design change that wasn't discussed first
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
## Requesting features
|
||||
|
||||
If you need a feature implemented, create a Github issue or a thread in Discord.
|
||||
|
||||
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
|
||||
|
||||
## AI assistance
|
||||
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
Using AI to help write code is fine, but you must:
|
||||
|
||||
- Ensure your agents read the docs
|
||||
- Understand the code you submit
|
||||
- Review and test the code yourself
|
||||
|
||||
173
README.ja.md
Normal file
173
README.ja.md
Normal file
@@ -0,0 +1,173 @@
|
||||
<p align="center">
|
||||
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
||||
</a>
|
||||
<a href="https://github.com/getpaseo/paseo/releases">
|
||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
||||
</a>
|
||||
<a href="https://x.com/moboudra">
|
||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
||||
</a>
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Claude Code、Codex、Copilot、OpenCode、Pi のエージェントを、ひとつのインターフェースで。</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo アプリのスクリーンショット" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo モバイルアプリ" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> 私はひとりでメンテナンスしているため、GitHub Issues を毎日確認できるとは限りません。
|
||||
> 急ぎの問題や作業がブロックされている場合は、[Discord](https://discord.gg/jz8T2uahpH) から連絡するのが一番早いです。
|
||||
|
||||
---
|
||||
|
||||
自分のマシンでエージェントを並列実行。スマートフォンからでもデスクからでも、開発を進めてリリースできます。
|
||||
|
||||
- **セルフホスト:** エージェントはあなたのマシン上で動作し、完全な開発環境を使用します。自分のツール・設定・スキルをそのまま活用できます。
|
||||
- **マルチプロバイダー:** Claude Code、Codex、Copilot、OpenCode、Pi を同一のインターフェースで利用。タスクに合ったモデルを選べます。
|
||||
- **音声コントロール:** 音声モードでタスクを口述したり問題を話し合ったりできます。ハンズフリーが必要なときに便利です。
|
||||
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
||||
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
||||
|
||||
## はじめかた
|
||||
|
||||
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
||||
|
||||
### 前提条件
|
||||
|
||||
エージェント CLI をひとつ以上インストールし、認証情報を設定しておく必要があります。
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
### デスクトップアプリ(推奨)
|
||||
|
||||
[paseo.sh/download](https://paseo.sh/download) または [GitHub のリリースページ](https://github.com/getpaseo/paseo/releases)からダウンロードしてください。アプリを開くとデーモンが自動的に起動します。追加のインストールは不要です。
|
||||
|
||||
スマートフォンから接続するには、Settings 画面に表示される QR コードをスキャンしてください。
|
||||
|
||||
### CLI / ヘッドレス
|
||||
|
||||
CLI をインストールして Paseo を起動します。
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
ターミナルに QR コードが表示されます。どのクライアントからでも接続できます。サーバーやリモートマシンでの利用に適しています。
|
||||
|
||||
詳しいセットアップと設定については以下を参照してください。
|
||||
|
||||
- [ドキュメント](https://paseo.sh/docs)
|
||||
- [設定リファレンス](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
アプリでできることはすべてターミナルからも実行できます。
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # 実行中のエージェントを一覧表示
|
||||
paseo attach abc123 # ライブ出力をストリーミング
|
||||
paseo send abc123 "also add tests" # 追加タスクを送信
|
||||
|
||||
# リモートデーモンで実行
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
詳細は[完全な CLI リファレンス](https://paseo.sh/docs/cli)を参照してください。
|
||||
|
||||
## スキル
|
||||
|
||||
スキルはエージェントに Paseo を使って他のエージェントをオーケストレーションする方法を教えます。
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
どのエージェントとの会話でも使用できます。
|
||||
|
||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||
|
||||
## 開発
|
||||
|
||||
モノレポのパッケージ構成:
|
||||
|
||||
- `packages/server`: Paseo デーモン(エージェントプロセスのオーケストレーション、WebSocket API、MCP サーバー)
|
||||
- `packages/app`: Expo クライアント(iOS、Android、Web)
|
||||
- `packages/cli`: デーモンおよびエージェントワークフロー向け `paseo` CLI
|
||||
- `packages/desktop`: Electron デスクトップアプリ
|
||||
- `packages/relay`: リモート接続用リレーパッケージ
|
||||
- `packages/website`: マーケティングサイトとドキュメント(`paseo.sh`)
|
||||
|
||||
よく使うコマンド:
|
||||
|
||||
```bash
|
||||
# すべてのローカル開発サービスを起動
|
||||
npm run dev
|
||||
|
||||
# 個別のサービスを起動
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# サーバースタックをビルド
|
||||
npm run build:server
|
||||
|
||||
# リポジトリ全体のチェック
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
19
README.md
19
README.md
@@ -6,7 +6,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -87,6 +88,21 @@ For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
### Docker
|
||||
|
||||
Run the Paseo daemon and self-hosted web UI in Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:6767` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/paseo` volume. See the [Docker documentation](docs/docker.md) for full setup details.
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
@@ -153,6 +169,7 @@ npm run typecheck
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -153,6 +154,7 @@ npm run typecheck
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ Connected clients are trusted operators of the daemon user. File previews follow
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
`paseo` user by default. Mounted workspaces and credentials are still fully
|
||||
available to anything the agents run inside the container.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
17
docker/Dockerfile.agents.example
Normal file
17
docker/Dockerfile.agents.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Example child image that adds agent CLIs to the official Paseo image.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f docker/Dockerfile.agents.example -t paseo-with-agents .
|
||||
#
|
||||
# Then set `image: paseo-with-agents` in docker/docker-compose.example.yml.
|
||||
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
opencode-ai
|
||||
|
||||
# Leave the image user as root. The base entrypoint prepares mounted volumes,
|
||||
# then drops the daemon and launched agents to the non-root `paseo` user.
|
||||
30
docker/README.md
Normal file
30
docker/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Paseo Docker Image
|
||||
|
||||
This directory contains the official Paseo daemon image.
|
||||
|
||||
The image runs the daemon headless and serves the bundled web UI from the same
|
||||
HTTP origin. Start it, then open the daemon URL in a browser.
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open `http://localhost:6767`.
|
||||
|
||||
The base image intentionally does not bundle agent CLIs. Extend it with the
|
||||
agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security,
|
||||
agent auth, and troubleshooting notes.
|
||||
80
docker/base/Dockerfile
Normal file
80
docker/base/Dockerfile
Normal file
@@ -0,0 +1,80 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ARG PASEO_VERSION=latest
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
PASEO_WEB_UI_ENABLED=true \
|
||||
PASEO_LOG_FORMAT=json \
|
||||
PASEO_LOG_LEVEL=info \
|
||||
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||
CODEX_HOME=/home/paseo/.codex \
|
||||
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional \
|
||||
"@getpaseo/server@${PASEO_VERSION}" \
|
||||
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||
node --check "$server_entry"
|
||||
|
||||
RUN set -eux; \
|
||||
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||
groupmod --new-name paseo "$existing_group"; \
|
||||
elif [ -z "$existing_group" ]; then \
|
||||
groupadd --gid 1000 paseo; \
|
||||
fi; \
|
||||
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||
elif [ -z "$existing_user" ]; then \
|
||||
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||
fi; \
|
||||
mkdir -p \
|
||||
/workspace \
|
||||
"$PASEO_HOME" \
|
||||
"$CLAUDE_CONFIG_DIR" \
|
||||
"$CODEX_HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$XDG_STATE_HOME" \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 6767
|
||||
VOLUME ["/home/paseo"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||
101
docker/base/Dockerfile.source
Normal file
101
docker/base/Dockerfile.source
Normal file
@@ -0,0 +1,101 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||
|
||||
ARG PASEO_VERSION
|
||||
|
||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
WORKDIR /tmp/paseo-src
|
||||
COPY . .
|
||||
|
||||
RUN set -eux; \
|
||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||
npm ci
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
ENV HOME=/home/paseo \
|
||||
PASEO_HOME=/home/paseo/.paseo \
|
||||
PASEO_LISTEN=0.0.0.0:6767 \
|
||||
PASEO_WEB_UI_ENABLED=true \
|
||||
PASEO_LOG_FORMAT=json \
|
||||
PASEO_LOG_LEVEL=info \
|
||||
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||
CODEX_HOME=/home/paseo/.codex \
|
||||
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gosu \
|
||||
lbzip2 \
|
||||
openssh-client \
|
||||
tini; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||
RUN set -eux; \
|
||||
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
|
||||
rm -rf /tmp/paseo-packs; \
|
||||
npm cache clean --force; \
|
||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||
test -f "$server_entry"; \
|
||||
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||
node --check "$server_entry"
|
||||
|
||||
RUN set -eux; \
|
||||
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||
groupmod --new-name paseo "$existing_group"; \
|
||||
elif [ -z "$existing_group" ]; then \
|
||||
groupadd --gid 1000 paseo; \
|
||||
fi; \
|
||||
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||
elif [ -z "$existing_user" ]; then \
|
||||
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||
fi; \
|
||||
mkdir -p \
|
||||
/workspace \
|
||||
"$PASEO_HOME" \
|
||||
"$CLAUDE_CONFIG_DIR" \
|
||||
"$CODEX_HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$XDG_STATE_HOME" \
|
||||
"$XDG_CACHE_HOME"; \
|
||||
chown -R paseo:paseo /home/paseo /workspace
|
||||
|
||||
COPY docker/base/rootfs/ /
|
||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 6767
|
||||
VOLUME ["/home/paseo"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
78
docker/base/rootfs/usr/local/bin/paseo-docker-entrypoint
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_HOME="/home/paseo"
|
||||
|
||||
: "${HOME:=$IMAGE_HOME}"
|
||||
: "${PASEO_HOME:=${HOME}/.paseo}"
|
||||
: "${PASEO_LISTEN:=0.0.0.0:6767}"
|
||||
: "${PASEO_WEB_UI_ENABLED:=true}"
|
||||
: "${PASEO_LOG_LEVEL:=info}"
|
||||
: "${PASEO_LOG_FORMAT:=json}"
|
||||
: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}"
|
||||
: "${CODEX_HOME:=${HOME}/.codex}"
|
||||
: "${XDG_CONFIG_HOME:=${HOME}/.config}"
|
||||
: "${XDG_DATA_HOME:=${HOME}/.local/share}"
|
||||
: "${XDG_STATE_HOME:=${HOME}/.local/state}"
|
||||
: "${XDG_CACHE_HOME:=${HOME}/.cache}"
|
||||
|
||||
export HOME
|
||||
export PASEO_HOME
|
||||
export PASEO_LISTEN
|
||||
export PASEO_WEB_UI_ENABLED
|
||||
export PASEO_LOG_LEVEL
|
||||
export PASEO_LOG_FORMAT
|
||||
export CLAUDE_CONFIG_DIR
|
||||
export CODEX_HOME
|
||||
export XDG_CONFIG_HOME
|
||||
export XDG_DATA_HOME
|
||||
export XDG_STATE_HOME
|
||||
export XDG_CACHE_HOME
|
||||
|
||||
ensure_dir() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
local owner
|
||||
owner="$(stat -c "%u" "$dir")"
|
||||
if [[ "$owner" == "0" ]]; then
|
||||
chown paseo:paseo "$dir"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dir "$HOME"
|
||||
ensure_dir "$PASEO_HOME"
|
||||
ensure_dir "$CLAUDE_CONFIG_DIR"
|
||||
ensure_dir "$CODEX_HOME"
|
||||
ensure_dir "$XDG_CONFIG_HOME"
|
||||
ensure_dir "$XDG_DATA_HOME"
|
||||
ensure_dir "$XDG_STATE_HOME"
|
||||
ensure_dir "$XDG_CACHE_HOME"
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo "$@"
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [[ -z "${PASEO_PASSWORD:-}" ]]; then
|
||||
{
|
||||
echo "[paseo] WARNING: PASEO_PASSWORD is not set."
|
||||
echo "[paseo] The daemon accepts unauthenticated control connections from any client that can reach it."
|
||||
echo "[paseo] Set PASEO_PASSWORD for any published port or network-reachable deployment."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
if [[ ! -f /etc/paseo-server-entry ]]; then
|
||||
echo "[paseo] FATAL: /etc/paseo-server-entry is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
entry="$(cat /etc/paseo-server-entry)"
|
||||
echo "[paseo] starting daemon on ${PASEO_LISTEN} with web UI ${PASEO_WEB_UI_ENABLED}"
|
||||
if [[ "$(id -u)" == "0" ]]; then
|
||||
exec gosu paseo node "$entry"
|
||||
fi
|
||||
exec node "$entry"
|
||||
21
docker/docker-compose.example.yml
Normal file
21
docker/docker-compose.example.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Minimal Paseo daemon + web UI deployment.
|
||||
#
|
||||
# Open http://localhost:6767 after `docker compose up -d`.
|
||||
# For any network-reachable deployment, change PASEO_PASSWORD first.
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
container_name: paseo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
# Add DNS names you use to reach this container. IPs and localhost are
|
||||
# already allowed by default.
|
||||
# PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
volumes:
|
||||
# Persistent daemon state and agent credentials/config.
|
||||
- ./paseo-home:/home/paseo
|
||||
# Code visible to Paseo and the agents it launches.
|
||||
- ./workspace:/workspace
|
||||
@@ -14,14 +14,23 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||
|
||||
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
|
||||
- `relationship` decides whether the new agent belongs under the caller.
|
||||
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
|
||||
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
|
||||
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -58,7 +67,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
|
||||
|
||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||
|
||||
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||
Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||
|
||||
## The subagents track
|
||||
|
||||
@@ -70,6 +79,8 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
|
||||
@@ -77,6 +88,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
|
||||
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
|
||||
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
|
||||
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
|
||||
- **Detach button on track rows** — lets a subagent continue independently without killing its work
|
||||
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
|
||||
|
||||
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
|
||||
@@ -97,13 +109,15 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -48,8 +48,9 @@ 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
|
||||
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
@@ -62,7 +63,8 @@ All paths are under `packages/server/src/`.
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
@@ -87,7 +89,7 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
@@ -167,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
@@ -232,7 +236,38 @@ initializing → idle ⇄ running
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
|
||||
|
||||
## Right-sidebar boundary: directory-backed vs workspace-owned
|
||||
|
||||
Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**.
|
||||
|
||||
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
|
||||
|
||||
| Surface | Key | Source |
|
||||
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
|
||||
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
|
||||
|
||||
| State | Key builder / store | Source |
|
||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
|
||||
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
|
||||
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
|
||||
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
|
||||
|
||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
|
||||
|
||||
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
|
||||
|
||||
One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is.
|
||||
|
||||
## Agent providers
|
||||
|
||||
@@ -258,6 +293,8 @@ All providers:
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
|
||||
@@ -438,7 +438,7 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -27,6 +27,9 @@ $PASEO_HOME/
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ └── workspaces.json # Workspace registry
|
||||
├── runtime/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -40,29 +43,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
@@ -126,6 +130,14 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
---
|
||||
|
||||
## Runtime-only Terminal Sessions
|
||||
|
||||
Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status.
|
||||
|
||||
Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped.
|
||||
|
||||
---
|
||||
|
||||
## 2. Daemon Configuration
|
||||
|
||||
**Path:** `$PASEO_HOME/config.json`
|
||||
@@ -138,6 +150,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
@@ -151,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -181,6 +199,41 @@ All fields are optional with sensible defaults.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
@@ -386,16 +439,25 @@ emptied duplicate.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||
|
||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
|
||||
than treating it as valid.
|
||||
|
||||
---
|
||||
|
||||
@@ -430,6 +492,13 @@ These small files are not validated as full Zod schemas but are persisted under
|
||||
|
||||
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
||||
|
||||
### Keying convention: directory-backed vs workspace-owned
|
||||
|
||||
Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table.
|
||||
|
||||
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
||||
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
||||
|
||||
### Draft Store
|
||||
|
||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||
|
||||
@@ -49,6 +49,12 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Expo Router
|
||||
|
||||
Route ownership, startup restore, and native blank-screen gotchas live in
|
||||
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
|
||||
startup routing, remembered workspace restore, or active workspace selection.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
||||
@@ -163,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Workspace search
|
||||
|
||||
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
|
||||
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
|
||||
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
|
||||
matcher in Paseo. If ignore semantics need to change, change the backend
|
||||
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
@@ -204,6 +218,56 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
|
||||
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
||||
|
||||
Enable it for a running daemon with:
|
||||
|
||||
```bash
|
||||
paseo daemon start --web-ui
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"webUi": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
|
||||
|
||||
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
|
||||
|
||||
Build the artifact for packaging or measurement with:
|
||||
|
||||
```bash
|
||||
npm run build:daemon-web-ui
|
||||
```
|
||||
|
||||
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
|
||||
|
||||
Measured bundle size for a standard Expo web export:
|
||||
|
||||
- raw: 10.77 MiB
|
||||
- gzip: 2.55 MiB
|
||||
- brotli: 1.93 MiB
|
||||
|
||||
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
|
||||
|
||||
## Built workspace packages
|
||||
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
239
docs/docker.md
Normal file
239
docs/docker.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Running Paseo in Docker
|
||||
|
||||
Paseo publishes a container image for running the daemon on a server, VM, NAS,
|
||||
or homelab box. The image also serves the bundled browser web UI, so one
|
||||
container gives you both the daemon API and a self-hosted UI.
|
||||
|
||||
The image source lives in [`docker/`](../docker/).
|
||||
|
||||
## How it works
|
||||
|
||||
The official image:
|
||||
|
||||
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||
or from source-built workspace tarballs for beta images
|
||||
- runs the daemon as the non-root `paseo` user
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
- stores daemon state and agent credentials under `/home/paseo`
|
||||
- leaves agent CLIs out of the base image
|
||||
|
||||
Open the container's HTTP origin, for example `http://localhost:6767`, to load
|
||||
the web UI. The served app receives a same-origin connection hint and connects
|
||||
back to that daemon. Static UI files load without daemon auth; API and
|
||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:6767
|
||||
```
|
||||
|
||||
If you set `PASEO_PASSWORD`, enter the same password when adding the direct
|
||||
daemon connection in the web UI or another Paseo client.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Use [`docker/docker-compose.example.yml`](../docker/docker-compose.example.yml):
|
||||
|
||||
```bash
|
||||
cp docker/docker-compose.example.yml docker-compose.yml
|
||||
$EDITOR docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
paseo:
|
||||
image: ghcr.io/getpaseo/paseo:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
environment:
|
||||
PASEO_PASSWORD: "change-me"
|
||||
volumes:
|
||||
- ./paseo-home:/home/paseo
|
||||
- ./workspace:/workspace
|
||||
```
|
||||
|
||||
## Installing Agents
|
||||
|
||||
The base image does not preinstall Claude Code, Codex, OpenCode, Copilot, Pi, or
|
||||
other agent CLIs. That keeps the default image small and avoids coupling Paseo
|
||||
releases to third-party agent release cycles.
|
||||
|
||||
Create a child image for the agents you use:
|
||||
|
||||
```Dockerfile
|
||||
FROM ghcr.io/getpaseo/paseo:latest
|
||||
|
||||
USER root
|
||||
RUN npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
|
||||
```
|
||||
|
||||
Build it:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile -t paseo-with-agents .
|
||||
```
|
||||
|
||||
Then use `image: paseo-with-agents` in Compose.
|
||||
|
||||
Leave the child image user as root. The base entrypoint uses root only for
|
||||
first-run directory setup, then drops the daemon and launched agents to the
|
||||
non-root `paseo` user.
|
||||
|
||||
An example child image is in
|
||||
[`docker/Dockerfile.agents.example`](../docker/Dockerfile.agents.example).
|
||||
|
||||
You can also mount credentials from the host or run agent login once inside the
|
||||
container:
|
||||
|
||||
```bash
|
||||
docker exec -it --user paseo paseo codex
|
||||
docker exec -it --user paseo paseo claude
|
||||
```
|
||||
|
||||
Agent credentials and config persist in `/home/paseo`, alongside daemon state.
|
||||
Provider environment variables such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
|
||||
`OPENAI_BASE_URL`, or `ANTHROPIC_BASE_URL` can be passed through `docker run -e`
|
||||
or `compose.environment`; Paseo passes them to launched agents.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Purpose |
|
||||
| ------------- | ------------------------------------------------------------------------ |
|
||||
| `/home/paseo` | Paseo state under `.paseo` plus agent config such as `.codex`, `.claude` |
|
||||
| `/workspace` | Code that Paseo and launched agents can read and write |
|
||||
|
||||
The image defaults:
|
||||
|
||||
| Variable | Default |
|
||||
| -------------- | -------------------- |
|
||||
| `HOME` | `/home/paseo` |
|
||||
| `PASEO_HOME` | `/home/paseo/.paseo` |
|
||||
| `PASEO_LISTEN` | `0.0.0.0:6767` |
|
||||
|
||||
If you bind-mount host directories on Linux, make sure the container user can
|
||||
write them. The built-in `paseo` user has uid/gid `1000:1000`. For a different
|
||||
host uid/gid, either adjust ownership on the mounted directories or run the
|
||||
container with Docker's `--user` / Compose `user:` option.
|
||||
|
||||
## Reverse Proxies
|
||||
|
||||
When serving Paseo behind a reverse proxy, forward normal HTTP requests and
|
||||
WebSocket upgrades to the same daemon port.
|
||||
|
||||
Caddy example:
|
||||
|
||||
```caddy
|
||||
paseo.example.com {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
```
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name paseo.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6767;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you reach the daemon by DNS name, set `PASEO_HOSTNAMES` so host-header
|
||||
validation allows that name:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
PASEO_HOSTNAMES: "paseo.example.com,.lan"
|
||||
```
|
||||
|
||||
IPs and `localhost` are allowed by default.
|
||||
|
||||
## Security
|
||||
|
||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||
want to expose the daemon port directly.
|
||||
- The container is the isolation boundary for agents. Agents can read and write
|
||||
whatever you mount into `/workspace` and whatever credentials you place in
|
||||
`/home/paseo`.
|
||||
- The bundled web UI static files are public on the daemon origin. The daemon
|
||||
API and WebSocket remain protected by password auth when configured.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
||||
|
||||
## Building Locally
|
||||
|
||||
```bash
|
||||
docker build -t paseo:local docker/base
|
||||
```
|
||||
|
||||
To bake a specific published npm version:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PASEO_VERSION=0.1.102 \
|
||||
-t paseo:0.1.102 \
|
||||
docker/base
|
||||
```
|
||||
|
||||
The Docker workflow builds the image on pull requests and on `main` as a
|
||||
non-publishing check. Stable `vX.Y.Z` tag pushes publish
|
||||
`ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`. Beta tags
|
||||
publish only the exact prerelease tag, such as
|
||||
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
|
||||
|
||||
To replace a Docker image in place without rebuilding desktop, APK, or EAS
|
||||
mobile release artifacts, dispatch the Docker workflow manually instead of
|
||||
pushing a `v*` release tag:
|
||||
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=0.1.102-beta.1 \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||
versions build from the checked-out source tree by default and publish only the
|
||||
exact prerelease image tag.
|
||||
|
||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The web UI loads but cannot connect**: if `PASEO_PASSWORD` is set, add a
|
||||
direct connection with the same password.
|
||||
- **403 Host not allowed**: set `PASEO_HOSTNAMES` to the DNS names you use.
|
||||
- **Provider not available**: install that agent CLI in a child image or mount a
|
||||
runtime where the binary is on `PATH`.
|
||||
- **Permission errors in `/workspace`**: make the mounted directory writable by
|
||||
uid/gid `1000:1000`, or run the container as the host uid/gid.
|
||||
- **Logs**: inspect `docker logs paseo` or
|
||||
`/home/paseo/.paseo/daemon.log` inside the container.
|
||||
120
docs/expo-router.md
Normal file
120
docs/expo-router.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Expo Router
|
||||
|
||||
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
|
||||
not fail loudly when a nested native route is mounted under the wrong layout. The
|
||||
usual symptom is a white or blank native screen with no JavaScript crash.
|
||||
|
||||
Read this before changing `packages/app/src/app`, startup routing, remembered
|
||||
workspace restore, or active workspace selection.
|
||||
|
||||
## Ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory.
|
||||
|
||||
- The root layout registers `h/[serverId]`.
|
||||
- The root layout does not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index`.
|
||||
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
||||
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
||||
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
|
||||
|
||||
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
||||
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
||||
shape can leave a nested index route mounted without its local dynamic params and
|
||||
render a blank screen.
|
||||
|
||||
## Startup
|
||||
|
||||
The root `/` route chooses a host boundary. It does not jump directly into a host
|
||||
leaf.
|
||||
|
||||
- Good: `/` -> `/h/[serverId]`
|
||||
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
|
||||
|
||||
`/h/[serverId]` is the host home route. The host index restores the last
|
||||
remembered workspace for that host after the remembered selection has hydrated
|
||||
and the workspace has not been proven missing. If there is no restorable
|
||||
workspace, it goes to global `/open-project`.
|
||||
|
||||
This restore is based on the last navigated workspace, not current connection
|
||||
status. Do not redirect to another online host just because the remembered host
|
||||
is still connecting or offline; the workspace screen owns that offline/loading
|
||||
state.
|
||||
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||
leaf workspace URL.
|
||||
|
||||
The root stack owns `h/[serverId]`; the host stack owns
|
||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||
root host route and pass the nested workspace screen, or Expo Router can append
|
||||
extra hidden workspace deck entries.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
Do not paper over missing required params by reading global params in the leaf.
|
||||
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
|
||||
startup route shape.
|
||||
|
||||
Use the host route context for host-owned leaves that need the host id after
|
||||
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
|
||||
unmatched tree by guessing from global state.
|
||||
|
||||
## App Directory
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree.
|
||||
|
||||
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
|
||||
non-route directory.
|
||||
|
||||
## Native Stack
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId` and
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
route-tree state, so a real regression should launch native with seeded persisted
|
||||
state:
|
||||
|
||||
1. Seed `paseo:last-workspace-route-selection` with a valid
|
||||
`{ serverId, workspaceId }`.
|
||||
2. Launch the native app cold.
|
||||
3. Assert a real screen is visible, not the blank tree.
|
||||
4. Assert no `[Layout children]` warning appears.
|
||||
|
||||
The pure policy tests should still enforce the boundary split:
|
||||
|
||||
- root startup with a saved workspace returns `/h/[serverId]`;
|
||||
- host index with the same saved workspace returns
|
||||
`/h/[serverId]/workspace/[workspaceId]`;
|
||||
- host index with no restorable workspace returns `/open-project`.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before landing route changes:
|
||||
|
||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||
- [ ] Did an app-wide route return to a workspace? Use
|
||||
`navigateToHostWorkspaceRoute()`.
|
||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||
before stores, themes, or rendering.
|
||||
@@ -60,12 +60,11 @@ quietly relying on:
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||
together because they share the SharedValue. A portal'd popover is outside
|
||||
the composer tree — it does not get that transform unless you apply it
|
||||
yourself.
|
||||
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||
translate/padding styles. The composer and chat content must both read that
|
||||
provider-owned value. A portal'd popover is outside the composer tree — it
|
||||
does not get that transform unless you apply it yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
@@ -86,7 +85,8 @@ with Reanimated worklets the result is not always stable.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
||||
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||
the composer uses**:
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
@@ -95,7 +95,10 @@ the keyboard by re-measuring on every frame. Instead,
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed.
|
||||
lockstep, no re-measurement needed. Do not call
|
||||
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
@@ -13,6 +14,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
|
||||
@@ -8,10 +8,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
- `ar`
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ja`
|
||||
- `pt-BR`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
|
||||
|
||||
## Translation Scope
|
||||
|
||||
@@ -33,7 +35,7 @@ Run:
|
||||
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
```
|
||||
|
||||
The parity test catches missing keys between English and Simplified Chinese resources.
|
||||
The parity test catches missing keys across English and every supported locale resource.
|
||||
|
||||
## Migration Order
|
||||
|
||||
@@ -77,3 +79,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
|
||||
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
|
||||
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
|
||||
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
|
||||
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
|
||||
|
||||
@@ -10,12 +10,16 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
|
||||
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
@@ -26,31 +30,58 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
## Provider Helper Processes
|
||||
|
||||
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
|
||||
|
||||
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
|
||||
|
||||
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
|
||||
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
|
||||
|
||||
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
|
||||
|
||||
- `providerId`, `displayName`, `status`, and optional `planLabel`
|
||||
- any number of `windows` such as Session, Weekly, or Biweekly
|
||||
- optional `balances` for credits, USD, requests, or tokens
|
||||
- optional `details` for provider-specific rows
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
|
||||
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
|
||||
|
||||
---
|
||||
|
||||
@@ -313,14 +344,13 @@ interface AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listImportableSessions?(
|
||||
listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession?(
|
||||
importSession(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
@@ -343,7 +373,7 @@ interface AgentSession {
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void | AgentProviderNotice>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
@@ -355,7 +385,7 @@ interface AgentSession {
|
||||
// Optional:
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
@@ -363,6 +393,8 @@ interface AgentSession {
|
||||
}
|
||||
```
|
||||
|
||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||
|
||||
@@ -13,6 +13,15 @@ Each domain becomes a controller class in its own file with the **exact** contra
|
||||
|
||||
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
|
||||
|
||||
## Progress (shipped — diverged from the original filenames)
|
||||
|
||||
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
@@ -117,7 +126,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
|
||||
|
||||
## Slice 7 — VoiceSessionController (XL)
|
||||
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
|
||||
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ Rules that apply to both steps:
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -47,7 +48,9 @@ Before running any stable patch release command:
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
@@ -78,7 +81,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
|
||||
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -260,7 +263,8 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -269,9 +273,31 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
For Docker-only retries, **do not push or force-push a `v*` release tag**.
|
||||
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
|
||||
and EAS mobile release builds. Use the Docker workflow dispatch instead:
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
```bash
|
||||
gh workflow run docker.yml \
|
||||
--ref main \
|
||||
-f paseo_version=X.Y.Z-beta.N \
|
||||
-f publish=true \
|
||||
-f source_build=auto
|
||||
```
|
||||
|
||||
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
|
||||
desktop, APK, or EAS release builders. The Docker exception is safe because the
|
||||
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
|
||||
not check out or move the `v*` release tag.
|
||||
|
||||
To retry a failed non-Docker release workflow, push a retry tag on the commit
|
||||
you want to build. Reusing the same tag name is expected: move it with
|
||||
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||
you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
@@ -313,20 +339,24 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` only lists stable releases. Betas are silent.
|
||||
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
|
||||
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
|
||||
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
|
||||
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
|
||||
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -417,24 +447,31 @@ Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sa
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
|
||||
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
|
||||
|
||||
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
|
||||
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
|
||||
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
|
||||
@@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"trustedProxies": ["loopback", "172.16.0.0/12"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
@@ -77,6 +90,7 @@ server {
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
112
docs/terminal-activity.md
Normal file
112
docs/terminal-activity.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Terminal Activity Indicators
|
||||
|
||||
Paseo surfaces terminal activity as a tab indicator (the same "running" dot used by agents).
|
||||
|
||||
## Current state
|
||||
|
||||
Terminal activity is source-agnostic plumbing. `TerminalActivityTracker` holds the current per-terminal state and emits transitions to the manager, worker protocol, websocket subscription, app buckets, dots, and notifications.
|
||||
|
||||
The tracker defaults to unknown (`null`). Activity production lives outside terminal stream parsing: agent hook commands report coarse activity to the daemon's local `/api/terminal-activity` endpoint.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TerminalSession
|
||||
├── TerminalActivityTracker one per session
|
||||
│ ├── set(state) records the latest state
|
||||
│ └── onChange(snapshot, previous) fires only on resolved-state transitions
|
||||
│
|
||||
└── onActivityChange({ activity, previous }) subscribed in TerminalManager
|
||||
├── emits terminalsChanged terminal list/tab indicators only
|
||||
└── subscribeTerminalActivity per-transition stream for notification policy
|
||||
└── subscribeTerminalWorkspaceContributionChanged workspace status rollup only
|
||||
```
|
||||
|
||||
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
|
||||
|
||||
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`.
|
||||
|
||||
### Transitions carry their own history
|
||||
|
||||
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
|
||||
|
||||
The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
|
||||
|
||||
Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`.
|
||||
|
||||
Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists.
|
||||
|
||||
## Hook reporting
|
||||
|
||||
Terminals receive four environment variables when the daemon creates the shell:
|
||||
|
||||
- `PASEO_TERMINAL_ID`
|
||||
- `PASEO_ACTIVITY_TOKEN`
|
||||
- `PASEO_TERMINAL_ACTIVITY_URL`
|
||||
- `PASEO_HOOK_CLI` — absolute path to the current `paseo` CLI executable.
|
||||
|
||||
The generated shell command uses `PASEO_HOOK_CLI` to run the current CLI. `paseo hooks <agent> <event>` then reads the terminal id, token, and activity URL, asks the agent hook provider registry to resolve the event to a coarse activity state, and silently posts `{ terminalId, token, state }` to the activity URL. Missing env, unsupported agents/events, malformed hook input, and daemon/network failures are no-ops so agent hooks never break the user's terminal session.
|
||||
|
||||
Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Codex hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `PreToolUse`, `PostToolUse` → `running`
|
||||
- `PermissionRequest` → `needs-input`
|
||||
- `Stop` → `idle`
|
||||
|
||||
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
|
||||
|
||||
- `session.status` with `busy` or `retry` → `running`
|
||||
- `session.status` with `idle` → `idle`
|
||||
- `permission.asked` → `needs-input`
|
||||
- `permission.replied` → `running`
|
||||
|
||||
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
|
||||
|
||||
## Focus clearing
|
||||
|
||||
Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`.
|
||||
|
||||
### Agent hook installation
|
||||
|
||||
Installing hooks edits the user's real agent config files, so it is opt-in. The daemon setting
|
||||
`enableTerminalAgentHooks` (persisted under `daemon.enableTerminalAgentHooks`, default `false`)
|
||||
gates installation. It is surfaced in the app under a host's **Terminals** settings as "Enable
|
||||
terminal agent hooks" — "Get notifications and status from terminal agents. This installs hooks in
|
||||
your agent config files." `applyTerminalAgentHookSetting` reconciles the installed hooks with the
|
||||
setting: at startup it installs only when enabled; toggling the setting live installs on enable and
|
||||
removes Paseo's marker-matched hooks on disable. `paseo hooks` keeps working regardless — the gate
|
||||
only controls whether the daemon writes hooks into agent configs, not whether the CLI can post
|
||||
activity when the env is present.
|
||||
|
||||
When enabled, Paseo installs provider hooks globally:
|
||||
|
||||
- Claude hooks are written to `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR/settings.json` when that override is set).
|
||||
- Codex hooks are written to `~/.codex/hooks.json` (or `CODEX_HOME/hooks.json` when that override is set). Codex supports a native `commandWindows`, so each Paseo hook includes both POSIX and Windows commands. Non-managed Codex hooks are trust-gated by Codex; users may see Codex's hook review prompt before the hook runs.
|
||||
- OpenCode gets a self-contained plugin at `$XDG_CONFIG_HOME/opencode/plugins/paseo-terminal-activity.js` (or `~/.config/opencode/plugins/paseo-terminal-activity.js` when XDG is unset; `OPENCODE_CONFIG_DIR` still wins when set).
|
||||
|
||||
Installation is marker-based/idempotent for config hooks and exact-file/idempotent for the OpenCode plugin. Paseo preserves user hooks, removes only its own marker-matched command hooks, and leaves hooks installed across daemon shutdown. Outside a Paseo terminal they are inert because the command or plugin is gated on `PASEO_TERMINAL_ID`.
|
||||
|
||||
Provider variation lives in `AGENT_HOOK_PROVIDERS`: provider id, installed events, config install metadata, and runtime event-to-activity resolution. The daemon calls `installRegisteredAgentHooks()` once; the CLI calls `resolveHookActivity(provider, event, input)`. Adding a provider should add one provider entry and register it in `AGENT_HOOK_PROVIDERS`, without editing the generic CLI command or daemon bootstrap.
|
||||
|
||||
The installed hook command keeps the config portable and resolves the CLI at runtime:
|
||||
|
||||
```sh
|
||||
[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude <event>
|
||||
```
|
||||
|
||||
Codex also receives the Windows equivalent:
|
||||
|
||||
```bat
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
40
docs/terminal-performance.md
Normal file
40
docs/terminal-performance.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Terminal performance
|
||||
|
||||
How terminal output stays low-latency, what the invariants are, and how to measure before/after any change to the pipeline. Read this before touching anything under `packages/server/src/terminal/` or `packages/app/src/terminal/runtime/`.
|
||||
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
pty (node-pty, forked worker process)
|
||||
→ headless xterm parse (worker, snapshot fidelity)
|
||||
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
|
||||
→ process.send IPC → daemon main process
|
||||
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
|
||||
→ binary ws frame (2-byte header + raw bytes)
|
||||
→ client decode (daemon-client.ts) → stream router → emulator runtime
|
||||
→ xterm.write (back-to-back; xterm batches internally)
|
||||
```
|
||||
|
||||
Terminal frames share the daemon main event loop with all agent traffic. The `eventLoopDelay` block in the `ws_runtime_metrics` log line (every 30s in `daemon.log`) is the ground truth for "the daemon is busy" — p99/max there directly bound worst-case terminal frame delay.
|
||||
|
||||
## Invariants (the easy-to-break ones)
|
||||
|
||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
|
||||
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
|
||||
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
|
||||
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
|
||||
|
||||
## Known remaining contention (follow-up candidates)
|
||||
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
|
||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||
@@ -130,6 +130,8 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
|
||||
|
||||
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-62dqunclpCgWCsIyRV5DMjGdwUhPP4rbjEjSO9dJ7no=
|
||||
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||
|
||||
7149
package-lock.json
generated
7149
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.94",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
"development",
|
||||
"mcp",
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant"
|
||||
],
|
||||
@@ -56,6 +55,7 @@
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
@@ -6,13 +6,25 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
waitForScrollableChat,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
@@ -21,14 +33,96 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-away-",
|
||||
title: "Scroll-away anchor",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
|
||||
|
||||
const finalMetrics = await readScrollMetrics(page);
|
||||
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await withWorkspace({
|
||||
prefix: "stream-scroll-away-delayed-history-",
|
||||
});
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await selectModel(page, "Five minute stream");
|
||||
|
||||
const prompt = "Stream for delayed authoritative history scroll-away test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
@@ -37,10 +131,10 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await scrollAgentChatToBottom(page);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -23,15 +26,26 @@ import {
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let projectId: string;
|
||||
let workspaceId: string;
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
}
|
||||
projectId = created.workspace.projectId;
|
||||
workspaceId = created.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
@@ -39,10 +53,12 @@ test.describe("Archive tab reconciliation", () => {
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
@@ -68,7 +84,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
@@ -78,10 +94,12 @@ test.describe("Archive tab reconciliation", () => {
|
||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
@@ -94,7 +112,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await reloadWorkspace(page, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(page, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
@@ -105,25 +123,33 @@ test.describe("Archive tab reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||
workspaceId,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||
workspaceId,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await closeWorkspaceAgentTab(page, archived.id);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
131
packages/app/e2e/branch-switcher.spec.ts
Normal file
131
packages/app/e2e/branch-switcher.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
expectNoBranchSwitcherInWorkspaceHeader,
|
||||
expectWorkspaceBranch,
|
||||
openChangesPanel,
|
||||
switchBranchFromChangesPanel,
|
||||
} from "./helpers/branch-switcher";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
async function renameWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
input: { workspaceId: string; title: string },
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const renameItem = page.getByTestId(
|
||||
`sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`,
|
||||
);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
||||
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
||||
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
||||
await renameInput.fill(input.title);
|
||||
await page.getByTestId(`${modalPrefix}-submit`).click();
|
||||
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe("Branch switcher", () => {
|
||||
// The first test after a spec-file switch can fail while the shared daemon
|
||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "branch-switch-",
|
||||
repo: { branches: ["main", "dev"] },
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("dev");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "branch-coherence-",
|
||||
repo: { branches: ["main", "dev"] },
|
||||
});
|
||||
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
|
||||
const customTitle = "Payments Refactor";
|
||||
await renameWorkspaceViaSidebar(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: customTitle,
|
||||
});
|
||||
|
||||
// The header shows the custom title verbatim (a plain static title), never a
|
||||
// branch name, and the branch switcher does not live in the header.
|
||||
const headerTitle = page
|
||||
.getByTestId("workspace-header-title")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||
await expectNoBranchSwitcherInWorkspaceHeader(page);
|
||||
|
||||
// The diff panel's switcher tracks the real branch ("main"), not the title,
|
||||
// and switching it checks out the real branch on disk.
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
|
||||
// The custom title is unaffected by the branch switch.
|
||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("dev");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
49
packages/app/e2e/command-center-host.spec.ts
Normal file
49
packages/app/e2e/command-center-host.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
@@ -36,6 +37,11 @@ const MINIMAL_PNG = Buffer.from(
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
const TEST_JSON = {
|
||||
name: "config.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ composer: "drop" })),
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
@@ -172,6 +178,47 @@ test.describe("Composer attachments", () => {
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in active chat", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "attach-drop-new-workspace-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
@@ -193,7 +240,7 @@ test.describe("Composer attachments", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
@@ -205,8 +252,7 @@ test.describe("Composer attachments", () => {
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,7 +260,7 @@ test.describe("Composer attachments", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
@@ -226,8 +272,7 @@ test.describe("Composer attachments", () => {
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,7 +289,7 @@ test.describe("Composer attachments", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -142,6 +150,43 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||
page: Page,
|
||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const summary = await page
|
||||
.locator('[data-testid^="workspace-deck-entry-"]')
|
||||
.evaluateAll((elements, target) => {
|
||||
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
||||
const entries = elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
testId: element.getAttribute("data-testid"),
|
||||
hasLayout: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
});
|
||||
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
||||
return {
|
||||
totalDeckEntryCount: entries.length,
|
||||
currentWorkspaceEntryCount: currentEntries.length,
|
||||
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
||||
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
||||
};
|
||||
}, input);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalDeckEntryCount: input.expectedDeckEntryCount,
|
||||
currentWorkspaceEntryCount: 1,
|
||||
visibleCurrentWorkspaceEntryCount: 1,
|
||||
hiddenCurrentWorkspaceEntryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
@@ -166,6 +211,25 @@ async function openReadyMockAgent(
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
|
||||
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
|
||||
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
|
||||
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||
}
|
||||
|
||||
async function expectFileMentionSuggestion(
|
||||
page: Page,
|
||||
query: string,
|
||||
suggestion: string,
|
||||
): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await input.fill(query);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
@@ -308,6 +372,64 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
|
||||
try {
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-a-",
|
||||
title: "Autocomplete new route A",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-b-",
|
||||
title: "Autocomplete new route B",
|
||||
}),
|
||||
);
|
||||
sessions.push(
|
||||
await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-new-route-c-",
|
||||
title: "Autocomplete new route C",
|
||||
}),
|
||||
);
|
||||
|
||||
const [first, second, third] = sessions;
|
||||
|
||||
await openAgentRoute(page, first);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
|
||||
await openAppWideNewWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||
expectedDeckEntryCount: sessions.length,
|
||||
serverId,
|
||||
workspaceId: first.workspaceId,
|
||||
});
|
||||
|
||||
await composerLocator(page).fill("/");
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ hasText: "/help", visible: true })
|
||||
.first();
|
||||
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -418,6 +540,28 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-dot-entries-",
|
||||
title: "Dot file mention autocomplete",
|
||||
});
|
||||
|
||||
try {
|
||||
await seedDotPrefixedWorkspaceFiles(session.cwd);
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await expectFileMentionSuggestion(page, "@.env", ".env.local");
|
||||
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -228,6 +228,7 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await returnToWorkspaceChanges(page);
|
||||
await expectStoredCodeFontSize(page, 18);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
@@ -238,6 +239,9 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
@@ -270,10 +274,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const actualFontSize = await page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
expect(actualFontSize).toBe(fontSize);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
@@ -362,11 +369,13 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: opened.workspace.id };
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -398,6 +407,22 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number):
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
await expectStoredCodeFontSize(page, codeFontSize);
|
||||
}
|
||||
|
||||
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(
|
||||
(settingsKey) => localStorage.getItem(settingsKey),
|
||||
APP_SETTINGS_KEY,
|
||||
);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
|
||||
})
|
||||
.toBe(codeFontSize);
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
|
||||
209
packages/app/e2e/empty-project-persists.spec.ts
Normal file
209
packages/app/e2e/empty-project-persists.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Removing a project raises a browser confirm; accept it so the
|
||||
// user-confirmed removal proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
||||
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
||||
await removeItem.click();
|
||||
}
|
||||
|
||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: path.basename(projectPath) })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill("paseo-loading-state-no-match");
|
||||
|
||||
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||
// project row in place with a ghost "+ New workspace" child row.
|
||||
test.describe("Project with no workspaces persists", () => {
|
||||
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("empty-project-add-");
|
||||
const client = await connectSeedClient();
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
|
||||
projectId = await addProjectFromPicker(page, repo.path);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(path.basename(repo.path));
|
||||
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
|
||||
|
||||
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("archiving the only workspace keeps the project row with creation still reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const newWorkspaceRow = page.getByTestId(
|
||||
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
||||
);
|
||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The project survives a reload after its last workspace is archived.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Project remove", () => {
|
||||
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await removeProjectFromSidebar(page, workspace.projectId);
|
||||
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -10,6 +10,9 @@ import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
interface WaitForServerOptions {
|
||||
host?: string;
|
||||
@@ -23,7 +26,7 @@ async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
@@ -34,6 +37,14 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
const RESERVED_LOCAL_PORTS = new Set([
|
||||
// Default developer daemon.
|
||||
6767,
|
||||
// OpenCode's default local server port. Some provider probes can spawn it
|
||||
// during daemon startup, so the E2E daemon must not choose the same port.
|
||||
61680,
|
||||
]);
|
||||
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
@@ -146,6 +157,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSupervisorPidLock(home: string): Promise<number | null> {
|
||||
try {
|
||||
const content = await readFile(path.join(home, "paseo.pid"), "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
return typeof parsed.pid === "number" ? parsed.pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcessByPid(pid: number): Promise<void> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (isProcessRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCurrentDaemonFromPidLock(): Promise<void> {
|
||||
if (!paseoHome) {
|
||||
return;
|
||||
}
|
||||
if (process.env.E2E_DAEMON_PORT === "6767") {
|
||||
throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767.");
|
||||
}
|
||||
|
||||
const pid = await readSupervisorPidLock(paseoHome);
|
||||
if (pid === null) {
|
||||
return;
|
||||
}
|
||||
await stopProcessByPid(pid);
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
@@ -334,11 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
);
|
||||
}
|
||||
|
||||
interface DictationConfig {
|
||||
openAiUsable: boolean;
|
||||
localModelsDir: string | null;
|
||||
}
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
@@ -371,7 +438,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
async function logSpeechHarnessConfig(): Promise<void> {
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
@@ -382,23 +449,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
// Default app E2E does not cover speech flows. Keep speech disabled here so
|
||||
// unrelated tests never start background local-model downloads.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
return;
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
|
||||
);
|
||||
return { openAiUsable, localModelsDir };
|
||||
}
|
||||
|
||||
interface RelayStreamState {
|
||||
@@ -486,7 +550,7 @@ async function awaitRelayReady(
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -503,8 +567,18 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
relayProcess = spawn(
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
process.execPath,
|
||||
[
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
@@ -587,39 +661,28 @@ interface DaemonSpawnArgs {
|
||||
paseoHome: string;
|
||||
fakeEditorBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
}
|
||||
: {}),
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
@@ -651,23 +714,33 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
async function removeTempTree(targetPath: string): Promise<void> {
|
||||
await rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 40,
|
||||
retryDelay: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
await stopCurrentDaemonFromPidLock();
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome && shouldRemovePaseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await removeTempTree(paseoHome);
|
||||
paseoHome = null;
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeEditorBinDir) {
|
||||
await rm(fakeEditorBinDir, { recursive: true, force: true });
|
||||
await removeTempTree(fakeEditorBinDir);
|
||||
fakeEditorBinDir = null;
|
||||
}
|
||||
}
|
||||
@@ -677,8 +750,8 @@ export default async function globalSetup() {
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
await loadEnvTestFile(repoRoot);
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
const port = await getAvailablePortExcluding(new Set());
|
||||
const metroPort = await getAvailablePortExcluding(new Set([port]));
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
@@ -691,7 +764,7 @@ export default async function globalSetup() {
|
||||
|
||||
const cleanup = () => performCleanup(shouldRemovePaseoHome);
|
||||
|
||||
const dictation = await resolveDictationConfig();
|
||||
await logSpeechHarnessConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
@@ -707,7 +780,6 @@ export default async function globalSetup() {
|
||||
paseoHome,
|
||||
fakeEditorBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
@@ -54,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -66,3 +86,65 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function waitForScrollableChat(
|
||||
page: Page,
|
||||
input: { minScrollableDistance: number; timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight - metrics.viewportHeight;
|
||||
},
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
.toBeGreaterThan(input.minScrollableDistance);
|
||||
}
|
||||
|
||||
export async function scrollChatAwayFromBottom(
|
||||
page: Page,
|
||||
input: { deltaY: number; minDistanceFromBottom: number },
|
||||
): Promise<ScrollMetrics> {
|
||||
const scroll = getVisibleChatScroll(page);
|
||||
const box = await scroll.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(input.minDistanceFromBottom);
|
||||
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
|
||||
): Promise<void> {
|
||||
const durationMs = input?.durationMs ?? 2_000;
|
||||
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
|
||||
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
|
||||
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < durationMs) {
|
||||
await page.waitForTimeout(sampleIntervalMs);
|
||||
const metrics = await readScrollMetrics(page);
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
offsetY: metrics.offsetY,
|
||||
contentHeight: metrics.contentHeight,
|
||||
});
|
||||
expect(
|
||||
metrics.offsetY,
|
||||
JSON.stringify({ baseline, samples: samples.slice(-12) }),
|
||||
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
|
||||
}
|
||||
}
|
||||
|
||||
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
120
packages/app/e2e/helpers/agent-timeline-gate.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreatedAgentTimelineGate {
|
||||
release(): void;
|
||||
waitForCreatedAgent(): Promise<string>;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return message.payload && typeof message.payload === "object"
|
||||
? (message.payload as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
let createdAgentId: string | null = null;
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
let resolveForwardedResponse: (() => void) | null = null;
|
||||
const createdAgentSeen = new Promise<string>((resolve) => {
|
||||
resolveCreatedAgent = resolve;
|
||||
});
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
const forwardedResponse = new Promise<void>((resolve) => {
|
||||
resolveForwardedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
const forwardToClient = (message: WebSocketMessage) => {
|
||||
ws.send(message);
|
||||
resolveForwardedResponse?.();
|
||||
};
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
|
||||
const agentId = payload.agentId;
|
||||
if (typeof agentId === "string") {
|
||||
createdAgentId = agentId;
|
||||
resolveCreatedAgent?.(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMessage?.type === "fetch_agent_timeline_response") {
|
||||
const agentId = payload?.agentId;
|
||||
const direction = payload?.direction;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
typeof agentId === "string" &&
|
||||
agentId === createdAgentId &&
|
||||
direction === "tail"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
forwardToClient(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => forwardToClient(message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreatedAgent: () => createdAgentSeen,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
@@ -342,9 +342,17 @@ 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();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const modelButton = page
|
||||
.getByRole("button", { name: /Select model/i })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await modelButton.isVisible().catch(() => false)) {
|
||||
await modelButton.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
|
||||
@@ -3,17 +3,19 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildSessionsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
@@ -39,6 +41,7 @@ export interface IdleAgentSeedClient {
|
||||
model: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
}): Promise<{ id: string }>;
|
||||
waitForAgentUpsert(
|
||||
@@ -50,13 +53,14 @@ export interface IdleAgentSeedClient {
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; title: string },
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
@@ -71,6 +75,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +86,24 @@ export async function archiveAgentFromDaemon(
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
export function getWorktreeRestoreFeature(client: {
|
||||
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
||||
}): boolean {
|
||||
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
@@ -133,7 +156,7 @@ export async function openWorkspaceWithAgents(
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
|
||||
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
@@ -199,10 +222,8 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
|
||||
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -221,6 +242,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
67
packages/app/e2e/helpers/branch-switcher.ts
Normal file
67
packages/app/e2e/helpers/branch-switcher.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
// The branch switcher lives in the git diff panel's Changes header (right-side
|
||||
// ExplorerSidebar), not in the workspace header. It renders as a button whose
|
||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||
// what a screen reader user hears, and it proves the panel resolved a real
|
||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
return page
|
||||
.getByTestId("changes-header")
|
||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
}
|
||||
|
||||
// Opens the right-side explorer and lands on the Changes tab, where the branch
|
||||
// switcher and diff live. Git checkouts default to the Changes tab when the
|
||||
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||
await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function switchBranchFromChangesPanel(
|
||||
page: Page,
|
||||
input: { from: string; to: string },
|
||||
): Promise<void> {
|
||||
await branchSwitcherTrigger(page, input.from).click();
|
||||
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
await expect(picker).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The branch switcher combobox renders its options as plain text rows with no ARIA
|
||||
// role, so filter by the visible branch name and click the matching row. Filtering
|
||||
// first guarantees a single, unambiguous match.
|
||||
const search = page.getByPlaceholder("Filter branches...");
|
||||
await expect(search).toBeVisible({ timeout: 30_000 });
|
||||
await search.fill(input.to);
|
||||
|
||||
const option = picker.getByText(input.to, { exact: true });
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
|
||||
await expect(picker).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The workspace header title is a plain static title in Model B; the branch
|
||||
// switcher must never appear there. Asserting on the header testID keeps this
|
||||
// honest even as the switcher continues to exist inside the Changes panel.
|
||||
export async function expectNoBranchSwitcherInWorkspaceHeader(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-header-branch-switcher")).toHaveCount(0);
|
||||
}
|
||||
9
packages/app/e2e/helpers/command-center.ts
Normal file
9
packages/app/e2e/helpers/command-center.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { connectWorkspaceSetupClient } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
@@ -94,6 +95,33 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise<
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function dropFileOnComposer(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const dataTransfer = await page.evaluateHandle(
|
||||
({ name, mimeType, base64 }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
const droppedFile = new File([bytes], name, { type: mimeType });
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(droppedFile);
|
||||
return transfer;
|
||||
},
|
||||
{
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.buffer.toString("base64"),
|
||||
},
|
||||
);
|
||||
|
||||
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
|
||||
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("dragover", { dataTransfer });
|
||||
await composerRoot.dispatchEvent("drop", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
@@ -148,6 +176,7 @@ export async function selectGithubOption(
|
||||
export interface MockAgentSetup {
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
@@ -159,21 +188,35 @@ export async function startRunningMockAgent(
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspace = createdWorkspace.workspace;
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
workspaceId: workspace.id,
|
||||
model: opts.model,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { client, repo };
|
||||
return {
|
||||
client,
|
||||
repo,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
@@ -186,10 +229,20 @@ export async function openGithubWorkspace(
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
await openHomeWithProject(page, repoPath);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
const workspace = createdWorkspace.workspace;
|
||||
await gotoAppShell(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
url: resolveDaemonWsUrl(),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion,
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
159
packages/app/e2e/helpers/daemon-restart.ts
Normal file
159
packages/app/e2e/helpers/daemon-restart.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
@@ -48,11 +48,17 @@ export interface GhRepoFixture {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
defaultBranch: string;
|
||||
prs: GhPrFixture[];
|
||||
issues: GhIssueFixture[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface GhDefaultBranchClone {
|
||||
path: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
@@ -77,8 +83,9 @@ async function seedPr(args: {
|
||||
authedUrl: string;
|
||||
fullName: string;
|
||||
repoName: string;
|
||||
defaultBranch: string;
|
||||
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName, defaultBranch } = args;
|
||||
|
||||
const createArgs = [
|
||||
"pr",
|
||||
@@ -86,7 +93,7 @@ async function seedPr(args: {
|
||||
"--title",
|
||||
spec.title,
|
||||
"--base",
|
||||
"main",
|
||||
defaultBranch,
|
||||
"--head",
|
||||
branch,
|
||||
"--body",
|
||||
@@ -166,10 +173,11 @@ export async function createTempGithubRepo(options: {
|
||||
const { category, prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
|
||||
const defaultBranch = "main";
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["init", "-b", defaultBranch], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
@@ -197,7 +205,7 @@ export async function createTempGithubRepo(options: {
|
||||
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
|
||||
git(["add", `pr-${i + 1}.txt`], basePath);
|
||||
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
git(["checkout", defaultBranch], basePath);
|
||||
}
|
||||
|
||||
if (branches.length > 0) {
|
||||
@@ -217,6 +225,7 @@ export async function createTempGithubRepo(options: {
|
||||
authedUrl,
|
||||
fullName,
|
||||
repoName,
|
||||
defaultBranch,
|
||||
});
|
||||
localPaths.push(localPath);
|
||||
prFixtures.push(fixture);
|
||||
@@ -232,6 +241,7 @@ export async function createTempGithubRepo(options: {
|
||||
owner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
defaultBranch,
|
||||
prs: prFixtures,
|
||||
issues: issueFixtures,
|
||||
cleanup: async () => {
|
||||
@@ -247,3 +257,27 @@ export async function createTempGithubRepo(options: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function cloneGithubRepoDefaultBranchOnly(
|
||||
repo: Pick<GhRepoFixture, "fullName" | "name" | "defaultBranch">,
|
||||
): Promise<GhDefaultBranchClone> {
|
||||
const token = gh(["auth", "token"]);
|
||||
const authedUrl = `https://x-access-token:${token}@github.com/${repo.fullName}.git`;
|
||||
const clonePath = await mkdtemp(path.join("/tmp", `${repo.name}-${repo.defaultBranch}-only-`));
|
||||
|
||||
execFileSync(
|
||||
"git",
|
||||
["clone", "--quiet", "--single-branch", "--branch", repo.defaultBranch, authedUrl, clonePath],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
git(["config", "user.email", "e2e@paseo.test"], clonePath);
|
||||
git(["config", "user.name", "Paseo E2E"], clonePath);
|
||||
git(["config", "commit.gpgsign", "false"], clonePath);
|
||||
|
||||
return {
|
||||
path: clonePath,
|
||||
cleanup: async () => {
|
||||
await rm(clonePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
79
packages/app/e2e/helpers/hosts.ts
Normal file
79
packages/app/e2e/helpers/hosts.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { getServerId } from "./server-id";
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
client: SeedDaemonClient;
|
||||
cleanup(): Promise<void>;
|
||||
@@ -32,6 +33,7 @@ export async function seedMockAgentWorkspace(
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
@@ -40,6 +42,7 @@ export async function seedMockAgentWorkspace(
|
||||
});
|
||||
return {
|
||||
agentId: agent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
@@ -50,18 +53,16 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
export async function openAgentRoute(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(buildAgentRoute(input.cwd, input.agentId));
|
||||
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
@@ -3,37 +3,79 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { expectWorkspaceHeader } from "./workspace-ui";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "checkoutRefresh"
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "openProject"
|
||||
| "createWorkspace"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
>;
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
|
||||
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
function requireWorkspace(payload: WorkspacePayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.workspace) {
|
||||
throw new Error("openProject returned no workspace.");
|
||||
throw new Error("workspace.create returned no workspace.");
|
||||
}
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorkspaceById(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceDescriptor | null> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
return payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||
}
|
||||
|
||||
async function waitForWorkspaceDescriptor(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceDescriptor> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const workspace = await fetchWorkspaceById(client, workspaceId);
|
||||
if (workspace) {
|
||||
return workspace;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Workspace descriptor not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const match = pathname.match(
|
||||
@@ -55,25 +97,28 @@ export async function openProjectViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<OpenedProject> {
|
||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
const workspace = requireWorkspace(
|
||||
await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
}),
|
||||
);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
workspaceDirectory: string,
|
||||
options?: { scope?: "workspace" | "worktree" },
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath: workspaceDirectory,
|
||||
...(options?.scope !== undefined ? { scope: options.scope } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
if (!payload.success) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
throw new Error(`Failed to archive workspace: ${workspaceDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,12 +144,7 @@ export async function createWorktreeViaDaemon(
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
@@ -119,15 +159,15 @@ export async function openNewWorkspaceComposer(
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-new-workspace").click();
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -163,6 +203,61 @@ export async function clickNewWorkspaceButton(
|
||||
await submitNewWorkspacePrompt(page, input.prompt);
|
||||
}
|
||||
|
||||
export async function selectNewWorkspaceProject(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
|
||||
const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`);
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
|
||||
}
|
||||
|
||||
// The isolation trigger renders the active isolation's label ("Local" / "New
|
||||
// worktree"), so asserting its text proves what the screen currently remembers.
|
||||
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
|
||||
local: "Local",
|
||||
worktree: "New worktree",
|
||||
};
|
||||
|
||||
export async function expectWorkspaceIsolationSelected(
|
||||
page: Page,
|
||||
isolation: "local" | "worktree",
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Workspace isolation" });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
|
||||
}
|
||||
|
||||
export async function selectWorkspaceIsolation(
|
||||
page: Page,
|
||||
isolation: "local" | "worktree",
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("workspace-create-isolation-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
|
||||
// "New worktree" is only listed once the checkout status query confirms the
|
||||
// selected project is a git repo, so wait for the option to appear before
|
||||
// clicking it.
|
||||
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
@@ -240,43 +335,50 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
client: NewWorkspaceDaemonClient;
|
||||
previousWorkspaceId: string;
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
): Promise<{ workspaceId: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
let workspaceId: string | null = null;
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId);
|
||||
|
||||
if (input.assertSidebarRow !== false) {
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
`sidebar-workspace-row-${input.serverId}:${workspace.id}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
if (input.assertHeader !== false) {
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
title: workspace.name,
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
return { workspaceId };
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
45
packages/app/e2e/helpers/pins.ts
Normal file
45
packages/app/e2e/helpers/pins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { PinnedTabTarget } from "../../src/workspace-pins/target";
|
||||
import { pinnedTargetKey } from "../../src/workspace-pins/target";
|
||||
|
||||
// The new-tab dropdown menu items carry no stable web ARIA role for the inline
|
||||
// pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is
|
||||
// addressed through the test ids the feature assigns per target key — the same
|
||||
// escape-hatch convention the sidebar kebab and tab context menus use.
|
||||
|
||||
function pinToggle(page: Page, target: PinnedTabTarget) {
|
||||
return page.getByTestId(`workspace-pin-toggle-${pinnedTargetKey(target)}`);
|
||||
}
|
||||
|
||||
function menuItemFor(page: Page, target: PinnedTabTarget) {
|
||||
if (target.kind === "draft") {
|
||||
return page.getByTestId("workspace-new-tab-menu-agent");
|
||||
}
|
||||
return page.getByTestId(`workspace-new-tab-menu-${target.kind}`);
|
||||
}
|
||||
|
||||
export function tabRowPin(page: Page, target: PinnedTabTarget) {
|
||||
return page.getByTestId(`workspace-pinned-target-${pinnedTargetKey(target)}`);
|
||||
}
|
||||
|
||||
export async function openNewTabMenu(page: Page): Promise<void> {
|
||||
const trigger = page
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
// The pin toggle is hidden behind reveal-on-hover (opacity + pointerEvents) on
|
||||
// desktop web, so the menu item must be hovered before the toggle is clickable.
|
||||
export async function togglePinFromMenu(page: Page, target: PinnedTabTarget): Promise<void> {
|
||||
await openNewTabMenu(page);
|
||||
const item = menuItemFor(page, target);
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.hover();
|
||||
const toggle = pinToggle(page, target);
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
154
packages/app/e2e/helpers/provider-usage.ts
Normal file
154
packages/app/e2e/helpers/provider-usage.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,5 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface AgentHandle {
|
||||
page: Page;
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
@@ -61,14 +63,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
function agentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
async function openAgent(
|
||||
page: Page,
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(agentRoute(input.workspaceId, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
@@ -167,27 +172,33 @@ export async function launchAgent(input: {
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
await openAgent(input.page, { workspaceId: createdWorkspace.workspace.id, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.removeProject(handle.projectId).catch(() => undefined);
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
17
packages/app/e2e/helpers/route-assertions.ts
Normal file
17
packages/app/e2e/helpers/route-assertions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectAppRoute(
|
||||
page: Page,
|
||||
expectedRoute: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const current = new URL(page.url());
|
||||
return `${current.pathname}${current.search}`;
|
||||
},
|
||||
{ timeout: options?.timeout },
|
||||
)
|
||||
.toBe(expectedRoute);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
export interface SeedWorkspaceDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
@@ -12,36 +22,91 @@ import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
export interface SeedDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
| { kind: "directory"; path: string; projectId?: string }
|
||||
| {
|
||||
kind: "worktree";
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
refName?: string;
|
||||
baseBranch?: string;
|
||||
githubPrNumber?: number;
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
title?: string;
|
||||
}): Promise<{
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
/**
|
||||
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
||||
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
||||
* working-tree write authoritative before asserting on it in the UI, instead
|
||||
* of racing the filesystem watcher's debounce.
|
||||
*/
|
||||
checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
requestId?: string,
|
||||
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
listTerminals(
|
||||
cwd?: string,
|
||||
requestId?: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<{
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
activity?: TerminalActivity | null;
|
||||
}>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
entries: Array<{
|
||||
agent: {
|
||||
id: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
model: string | null;
|
||||
currentModeId: string | null;
|
||||
status: string;
|
||||
title?: string | null;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
@@ -67,6 +132,12 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
@@ -123,19 +194,23 @@ export async function seedWorkspace(options: {
|
||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(project.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
const workspace = created.workspace;
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import {
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
@@ -25,7 +31,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -33,12 +39,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await expectAppRoute(page, buildProjectsSettingsRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
await expectAppRoute(page, buildSettingsSectionRoute(section));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
@@ -55,9 +61,7 @@ export async function openSettingsHostSection(
|
||||
section: HostSection,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(`settings-host-section-${section}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
|
||||
);
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section));
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
@@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
|
||||
await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -149,7 +153,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string):
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
@@ -189,7 +193,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
@@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
|
||||
);
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
@@ -249,6 +251,7 @@ export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Run" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -374,6 +377,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
@@ -389,9 +393,9 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
|
||||
// open) that surfaces the local host by its label. The "Local" marker only
|
||||
// appears on dropdown rows in the multi-host case, which this fixture does not
|
||||
// exercise.
|
||||
// open) that surfaces the local host by its label. The per-row connection
|
||||
// endpoint only appears on dropdown rows in the multi-host case, which this
|
||||
// fixture does not exercise.
|
||||
const picker = sidebar.getByTestId("settings-host-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
|
||||
@@ -7,12 +7,55 @@ export async function selectWorkspaceInSidebar(page: Page, workspaceId: string):
|
||||
await row.click();
|
||||
}
|
||||
|
||||
async function openWorkspaceSidebarKebab(page: Page, workspaceId: string) {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
return serverId;
|
||||
}
|
||||
|
||||
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
|
||||
// suite addresses them by the stable test ids the app assigns per workspace — the same
|
||||
// convention the rename flow uses. The kebab only reveals on hover.
|
||||
export async function clickArchiveWorkspaceMenuItem(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceAbsentFromSidebar(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
32
packages/app/e2e/helpers/speech-env.ts
Normal file
32
packages/app/e2e/helpers/speech-env.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
const DISABLED_E2E_SPEECH_ENV = {
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
} as const;
|
||||
|
||||
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
// Default app E2E does not cover speech flows; keep restarts from starting
|
||||
// background local-model downloads for unrelated tests.
|
||||
const next: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
...DISABLED_E2E_SPEECH_ENV,
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Route } from "@playwright/test";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
@@ -6,6 +7,7 @@ const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const E2E_KEY = "@paseo:e2e";
|
||||
const STORAGE_SEED_HTML = "<!doctype html><html><body>storage seed</body></html>";
|
||||
|
||||
interface SavedHostInput {
|
||||
serverId: string;
|
||||
@@ -88,9 +90,9 @@ class StartupScenario {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the shared fixture create its seed nonce, then opt out of that seed for
|
||||
// the next navigation so this scenario owns the stored host registry.
|
||||
await this.page.goto("/");
|
||||
// Create same-origin storage without booting the app. If the default host runtime
|
||||
// starts first, it can race this scenario's registry override during CI.
|
||||
await openStorageSeedDocument(this.page);
|
||||
const nowIso = new Date().toISOString();
|
||||
const registry = this.savedHosts.map((host) =>
|
||||
buildStoredHost({
|
||||
@@ -132,6 +134,21 @@ class StartupScenario {
|
||||
}
|
||||
}
|
||||
|
||||
async function openStorageSeedDocument(page: Page): Promise<void> {
|
||||
await page.route(
|
||||
"**/*",
|
||||
async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: STORAGE_SEED_HTML,
|
||||
});
|
||||
},
|
||||
{ times: 1 },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
class StartupAssertions {
|
||||
private readonly page: Page;
|
||||
|
||||
@@ -139,13 +156,15 @@ class StartupAssertions {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async expectsSavedHostShell(input: { label: string }): Promise<this> {
|
||||
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
|
||||
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await this.page.getByTestId("sidebar-hosts-trigger").click();
|
||||
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
83
packages/app/e2e/helpers/subagents.ts
Normal file
83
packages/app/e2e/helpers/subagents.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
export interface SeededSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
child: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export async function seedParentWithSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
): Promise<SeededSubagentPair> {
|
||||
const parent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.parentTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
page.once("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("Detach subagent?");
|
||||
void dialog.accept();
|
||||
});
|
||||
|
||||
const detachButton = page.getByTestId(`subagents-track-detach-${childId}`);
|
||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||
await detachButton.click();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
@@ -14,25 +15,40 @@ export interface TerminalInstance {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface CreateTerminalInput {
|
||||
name: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export class TerminalE2EHarness {
|
||||
readonly client: SeedDaemonClient;
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly projectId: string;
|
||||
readonly workspaceId: string;
|
||||
|
||||
private constructor(input: {
|
||||
client: SeedDaemonClient;
|
||||
tempRepo: TempRepo;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
this.client = input.client;
|
||||
this.tempRepo = input.tempRepo;
|
||||
this.projectId = input.projectId;
|
||||
this.workspaceId = input.workspaceId;
|
||||
}
|
||||
|
||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||
const client = await connectSeedClient();
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
const seedResult = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!seedResult.workspace) {
|
||||
await client.close().catch(() => {});
|
||||
await tempRepo.cleanup().catch(() => {});
|
||||
@@ -41,23 +57,69 @@ export class TerminalE2EHarness {
|
||||
return new TerminalE2EHarness({
|
||||
client,
|
||||
tempRepo,
|
||||
projectId: seedResult.workspace.projectId,
|
||||
workspaceId: seedResult.workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.client.removeProject(this.projectId).catch(() => {});
|
||||
await this.client.close().catch(() => {});
|
||||
await this.tempRepo.cleanup().catch(() => {});
|
||||
}
|
||||
|
||||
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
||||
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||
async createTerminal(input: CreateTerminalInput): Promise<TerminalInstance> {
|
||||
const options =
|
||||
input.command || input.args
|
||||
? {
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
workspaceId: this.workspaceId,
|
||||
}
|
||||
: { workspaceId: this.workspaceId };
|
||||
const result = await this.client.createTerminal(
|
||||
this.tempRepo.path,
|
||||
input.name,
|
||||
undefined,
|
||||
options,
|
||||
);
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
return result.terminal;
|
||||
}
|
||||
|
||||
async waitForTerminalActivity(input: {
|
||||
terminalId: string;
|
||||
state: TerminalActivityState | null;
|
||||
attentionReason?: TerminalActivity["attentionReason"] | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const timeoutMs = input.timeoutMs ?? 10_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await this.client.listTerminals(this.tempRepo.path, undefined, {
|
||||
workspaceId: this.workspaceId,
|
||||
});
|
||||
const terminal = result.terminals.find((entry) => entry.id === input.terminalId);
|
||||
const activity = terminal?.activity ?? null;
|
||||
const attentionMatches =
|
||||
input.attentionReason === undefined ||
|
||||
(activity?.attentionReason ?? null) === input.attentionReason;
|
||||
if ((activity?.state ?? null) === input.state && attentionMatches) {
|
||||
return;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
const attentionSuffix =
|
||||
input.attentionReason === undefined
|
||||
? ""
|
||||
: ` with attention ${input.attentionReason ?? "none"}`;
|
||||
throw new Error(
|
||||
`Timed out waiting for terminal ${input.terminalId} activity state ${input.state ?? "unknown"}${attentionSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
async killTerminal(terminalId: string): Promise<void> {
|
||||
await this.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline(
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
let client: WorkspaceSetupDaemonClient | null = null;
|
||||
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||
const worktrees: WorktreeRecord[] = [];
|
||||
const projectIds = new Set<string>();
|
||||
|
||||
const withWorkspace: WithWorkspace = async (options) => {
|
||||
if (!client) {
|
||||
@@ -60,14 +61,21 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
);
|
||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||
// Register the parent project so the sidebar lists it before we navigate.
|
||||
await client.openProject(repo.path);
|
||||
const added = await client.addProject(repo.path);
|
||||
if (!added.project) {
|
||||
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
|
||||
}
|
||||
projectIds.add(added.project.projectId);
|
||||
}
|
||||
|
||||
const opened = await client.openProject(workspacePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: workspacePath },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = opened.workspace.id;
|
||||
const workspaceId = created.workspace.id;
|
||||
projectIds.add(created.workspace.projectId);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
@@ -83,6 +91,11 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
return {
|
||||
withWorkspace,
|
||||
cleanup: async () => {
|
||||
if (client) {
|
||||
for (const projectId of projectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
for (const { repoPath, worktreePath } of worktrees) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
|
||||
@@ -11,13 +11,15 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
type WorkspaceSetupDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "addProject"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgent"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "listTerminals"
|
||||
| "openProject"
|
||||
| "removeProject"
|
||||
| "subscribeRawMessages"
|
||||
>;
|
||||
|
||||
@@ -36,9 +38,11 @@ export async function openProjectViaDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(repoPath);
|
||||
const result = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
});
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
throw new Error(result.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: result.workspace.id,
|
||||
@@ -51,7 +55,10 @@ export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
await openProjectViaDaemon(client, repoPath);
|
||||
const result = await client.addProject(repoPath);
|
||||
if (!result.project || result.error) {
|
||||
throw new Error(result.error ?? `Failed to add project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
@@ -261,7 +268,7 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
targetWorkspacePath: workspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,15 @@ export async function expectFirstTerminalTabContains(page: Page, text: string):
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectTerminalTabOpen(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
}
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the sidebar to show at least one project row, indicating that the
|
||||
* WebSocket connection is up and workspace hydration has completed.
|
||||
* Wait for the sidebar to show at least one project row. Use this after a spec
|
||||
* seeds a workspace/project; zero-project flows need their own assertion.
|
||||
*/
|
||||
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
||||
await page
|
||||
@@ -18,29 +18,12 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
|
||||
.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
export function workspaceLabelFromPath(value: string): string {
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise<void> {
|
||||
await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith("/var/")) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
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}"]`,
|
||||
);
|
||||
return page.locator(ids.join(",")).first();
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
}
|
||||
|
||||
export async function expectSidebarWorkspaceSelected(input: {
|
||||
@@ -69,16 +52,14 @@ export async function expectSidebarWorkspaceSelected(input: {
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
targetWorkspacePath: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
|
||||
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +70,10 @@ export async function waitForWorkspaceInSidebar(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const candidates = candidateWorkspaceIds(input.workspaceId);
|
||||
const selector = candidates
|
||||
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
|
||||
.join(",");
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
|
||||
await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({
|
||||
state: "visible",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
|
||||
@@ -3,6 +3,9 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEMP_CLEANUP_RETRIES = 5;
|
||||
const TEMP_CLEANUP_RETRY_DELAY_MS = 100;
|
||||
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
branchHeads: Record<string, string>;
|
||||
@@ -126,7 +129,12 @@ export const createTempGitRepo = async (
|
||||
path: repoPath,
|
||||
branchHeads,
|
||||
cleanup: async () => {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
await rm(repoPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: TEMP_CLEANUP_RETRIES,
|
||||
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -141,7 +149,12 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
await rm(dirPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: TEMP_CLEANUP_RETRIES,
|
||||
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,9 +58,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(PASEO_REPO_PATH);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: PASEO_REPO_PATH },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
@@ -69,11 +71,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
workspace: {
|
||||
client,
|
||||
repoPath: PASEO_REPO_PATH,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
workspaceName: createdWorkspace.workspace.name,
|
||||
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
|
||||
@@ -108,7 +108,14 @@ test.describe("Terminal title propagation", () => {
|
||||
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
|
||||
const result = await workspace.client.createTerminal(
|
||||
workspace.repoPath,
|
||||
"title-test",
|
||||
undefined,
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
@@ -138,7 +145,14 @@ test.describe("Terminal title propagation", () => {
|
||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
|
||||
const result = await workspace.client.createTerminal(
|
||||
workspace.repoPath,
|
||||
"debounce-test",
|
||||
undefined,
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
|
||||
220
packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts
Normal file
220
packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute } from "./helpers/mock-agent";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
},
|
||||
},
|
||||
mock: {
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
async function readCodexModePreference(page: Page): Promise<unknown> {
|
||||
return page.evaluate((preferencesKey) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providerPreferences?: Record<string, { mode?: unknown }>;
|
||||
};
|
||||
return parsed.providerPreferences?.codex?.mode ?? null;
|
||||
}, CREATE_AGENT_PREFERENCES_KEY);
|
||||
}
|
||||
|
||||
async function selectMode(page: Page, label: string): Promise<void> {
|
||||
const modeControl = page.getByTestId("mode-control").first();
|
||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||
await modeControl.click();
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
|
||||
const option = page
|
||||
.getByRole("dialog")
|
||||
.last()
|
||||
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||
await option.click({ force: true });
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCreateAgentRequest: () => createAgentSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace Codex mode preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
|
||||
|
||||
expect(createAgentRequest.config).toMatchObject({
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await expect
|
||||
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
|
||||
.toBe("full-access");
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Codex live mode preference e2e",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: seeded.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
202
packages/app/e2e/new-workspace-entry.spec.ts
Normal file
202
packages/app/e2e/new-workspace-entry.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace entry points", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("the global new-workspace button opens the New Workspace screen", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-new-workspace-host",
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const globalButton = page.getByTestId("sidebar-global-new-workspace");
|
||||
await expect(globalButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(page.getByTestId("host-chooser")).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the New Workspace screen hides the host selector when there is only one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectA: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-a-" });
|
||||
const projectB: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-b-" });
|
||||
const projectC: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-c-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Project A's row icon opens New Workspace with A preselected.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectA.projectId,
|
||||
projectDisplayName: projectA.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
|
||||
// Manually override the selection to C from inside A's screen. This stale
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
await page.getByTestId("new-workspace-project-picker-trigger").click();
|
||||
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
|
||||
// Navigate via B's row icon. B must be preselected — the route project wins
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
// reset were missing, the trigger would still read C.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: projectB.projectId,
|
||||
projectDisplayName: projectB.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
} finally {
|
||||
await projectA.cleanup();
|
||||
await projectB.cleanup();
|
||||
await projectC.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
|
||||
const nonGitProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "entry-iso-nongit-",
|
||||
git: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const nonGitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
|
||||
);
|
||||
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
|
||||
await nonGitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
|
||||
|
||||
// No git checkout means no worktree isolation choice: the Isolation row is
|
||||
// absent entirely.
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
|
||||
// Switching to the git project on the same screen reveals the Isolation row.
|
||||
await trigger.click();
|
||||
const gitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${gitProject.projectId}`,
|
||||
);
|
||||
await expect(gitOption).toBeVisible({ timeout: 30_000 });
|
||||
await gitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await gitProject.cleanup();
|
||||
await nonGitProject.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
96
packages/app/e2e/new-workspace-isolation-memory.spec.ts
Normal file
96
packages/app/e2e/new-workspace-isolation-memory.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Regression for "the local / worktree selection in the new workspace is not
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
// Second visit (fresh mount of /new): the worktree choice must stick.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
270
packages/app/e2e/new-workspace-preselect.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
} from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
|
||||
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const OFFLINE_SERVER_IDS = [
|
||||
"srv_e2e_preselect_offline_1",
|
||||
"srv_e2e_preselect_offline_2",
|
||||
"srv_e2e_preselect_offline_3",
|
||||
];
|
||||
|
||||
// New Workspace preselection is a form-context decision, not startup routing.
|
||||
// Entry points from a workspace should carry the current project context, and a
|
||||
// plain /new must not let a stale remembered offline host steal the initial host
|
||||
// when there is exactly one online saved host.
|
||||
|
||||
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+n`);
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
projectDisplayName: string,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
|
||||
}
|
||||
|
||||
async function expectAnyProjectPreselectedWithin(
|
||||
page: import("@playwright/test").Page,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const label = ((await projectPicker.textContent()) ?? "").trim();
|
||||
return label || "Choose project";
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.not.toBe("Choose project");
|
||||
}
|
||||
|
||||
async function openColdRestoredWorkspaceWithOfflineHostFirst(
|
||||
page: import("@playwright/test").Page,
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId, workspaceId }) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: connectedServerId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function openNewWorkspaceWithStaleOfflineSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(buildNewWorkspaceRoute());
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function seedOfflineHostsWithStaleSelection(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const connectedServerId = getServerId();
|
||||
await seedSavedSettingsHosts(page, [
|
||||
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||
serverId,
|
||||
label: `Offline host ${index + 1}`,
|
||||
endpoint: `127.0.0.1:${index + 1}`,
|
||||
})),
|
||||
{
|
||||
serverId: connectedServerId,
|
||||
label: "Connected host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
]);
|
||||
await page.evaluate(
|
||||
({ storageKey, serverId }) => {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||
);
|
||||
},
|
||||
{
|
||||
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("New workspace preselects the open workspace's project", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
let projectA: SeededWorkspace;
|
||||
let projectB: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
|
||||
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await projectA?.cleanup();
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
|
||||
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNewWorkspaceWithStaleOfflineSelection(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectAnyProjectPreselectedWithin(page, 8_000);
|
||||
});
|
||||
|
||||
test("stale remembered offline host heals after visiting the connected workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const connectedServerId = getServerId();
|
||||
await seedOfflineHostsWithStaleSelection(page);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,15 @@ import {
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
|
||||
import {
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
createTempGithubRepo,
|
||||
hasGithubAuth,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
@@ -36,7 +41,6 @@ import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface WorkspaceStatusGroupEvent {
|
||||
@@ -48,7 +52,7 @@ interface WorkspaceStatusGroupEvent {
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -183,7 +187,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -193,14 +197,14 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceId of createdWorktreeIds) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeIds.clear();
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
@@ -223,7 +227,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
@@ -233,7 +237,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId,
|
||||
@@ -247,7 +251,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
@@ -271,7 +275,7 @@ test.describe("New workspace flow", () => {
|
||||
slug: `nav-${Date.now()}`,
|
||||
});
|
||||
localWorkspaceIds.add(rootWorkspace.workspaceId);
|
||||
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -279,7 +283,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
@@ -294,7 +298,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: worktreeWorkspace.workspaceId,
|
||||
workspaceId: worktreeWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: worktreeWorkspace.workspaceName,
|
||||
@@ -315,7 +319,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
@@ -354,7 +358,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -367,12 +371,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||
await expect(page).toHaveURL(
|
||||
@@ -388,7 +393,7 @@ test.describe("New workspace flow", () => {
|
||||
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||
title: createdWorkspace.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
@@ -428,7 +433,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -455,12 +460,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||
@@ -507,7 +513,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -523,12 +529,13 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
@@ -560,7 +567,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -576,10 +583,11 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
@@ -618,7 +626,7 @@ test.describe("New workspace flow", () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
workspaceId: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
@@ -629,6 +637,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
@@ -640,17 +649,18 @@ test.describe("New workspace flow", () => {
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||
expect(existsSync(createdWorkspace.workspaceDirectory)).toBe(true);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceId,
|
||||
worktreePath: createdWorkspace.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
} finally {
|
||||
@@ -673,6 +683,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -697,6 +708,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -726,6 +738,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
|
||||
@@ -742,4 +755,50 @@ test.describe("New workspace flow", () => {
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "new-workspace-pr-worktree",
|
||||
prs: [{ title: "Checkout PR worktree", state: "open" }],
|
||||
});
|
||||
const pr = ghRepo.prs[0]!;
|
||||
const mainCheckout = await cloneGithubRepoDefaultBranchOnly(ghRepo);
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: worktree.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||
} finally {
|
||||
await mainCheckout.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -131,7 +133,64 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
return readFile(path.join(project.path, "paseo.json"), "utf8");
|
||||
}
|
||||
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: path.basename(projectPath) })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`);
|
||||
await expect(openSettingsItem).toBeVisible({ timeout: 10_000 });
|
||||
await openSettingsItem.click();
|
||||
}
|
||||
|
||||
test.describe("Projects settings", () => {
|
||||
test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||
const client = await connectSeedClient();
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
projectId = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectId);
|
||||
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
@@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-sheet-stack-",
|
||||
title: "Provider sheet stack e2e",
|
||||
initialPrompt: "Prepare provider sheet stack test agent.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
@@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function closeSheetByHeaderButton(page: Page, testId: string) {
|
||||
const sheet = page.getByTestId(testId);
|
||||
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
|
||||
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
@@ -69,14 +74,15 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Add model" }).click();
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "add-custom-model-sheet");
|
||||
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-diagnostic-sheet");
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
@@ -87,18 +93,20 @@ test.describe("provider settings bottom-sheet stack", () => {
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-settings-sheet");
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
await closeSheetByHeaderButton(page, "provider-settings-sheet");
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
152
packages/app/e2e/provider-usage-settings.spec.ts
Normal file
152
packages/app/e2e/provider-usage-settings.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { openSettingsHostSection } from "./helpers/settings";
|
||||
|
||||
test.describe("provider usage settings", () => {
|
||||
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
sourceLabel: "OpenUsage 0.6.27",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh invalidates and refetches usage", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("one provider error does not collapse the usage list", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Error", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("71%")).toBeVisible();
|
||||
});
|
||||
});
|
||||
113
packages/app/e2e/provider-usage-tooltip.spec.ts
Normal file
113
packages/app/e2e/provider-usage-tooltip.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-usage-tooltip-",
|
||||
title: "Provider usage tooltip e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("provider usage tooltip", () => {
|
||||
test("fetches usage when the context tooltip opens and renders the active provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: 42,
|
||||
remainingPct: 58,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
|
||||
await page.getByTestId("context-window-meter").hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Test plan")).toBeVisible();
|
||||
await expect(page.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("42%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 41 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
@@ -14,6 +15,14 @@ async function expectUserMessageCount(page: Page, expected: number): Promise<voi
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
function userMessage(page: Page, text: string): Locator {
|
||||
return page.getByTestId("user-message").filter({ hasText: text });
|
||||
}
|
||||
|
||||
async function expectUserMessageVisible(page: Page, text: string): Promise<void> {
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
@@ -29,14 +38,14 @@ test.describe("Rewind sheet", () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageVisible(page, firstPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await submitMessage(page, secondPrompt);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageVisible(page, secondPrompt);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await userMessage(page, firstPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
@@ -46,20 +55,20 @@ test.describe("Rewind sheet", () => {
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await expectComposerDraft(page, firstPrompt);
|
||||
|
||||
await submitMessage(page, replacementPrompt);
|
||||
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageVisible(page, replacementPrompt);
|
||||
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
@@ -70,7 +79,7 @@ test.describe("Rewind sheet", () => {
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
@@ -80,7 +89,7 @@ test.describe("Rewind sheet", () => {
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
@@ -108,9 +117,9 @@ test.describe("Rewind sheet", () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageVisible(page, firstPrompt);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await userMessage(page, firstPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
@@ -122,7 +131,7 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
|
||||
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await userMessage(page, firstPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
|
||||
120
packages/app/e2e/same-directory-workspaces.spec.ts
Normal file
120
packages/app/e2e/same-directory-workspaces.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { expectExplorerEntryVisible } from "./helpers/file-explorer";
|
||||
import { expectNoTerminalTabs, clickFirstTerminalTab } from "./helpers/workspace-tabs";
|
||||
|
||||
// Model B: two workspaces can back the SAME directory. What follows from that
|
||||
// split is the contract these specs pin:
|
||||
// - The right sidebar (file browser / git changes) reads the directory, so it
|
||||
// is IDENTICAL across same-directory workspaces.
|
||||
// - Tabs (agents, terminals) are owned by the workspace, so they are
|
||||
// INDEPENDENT across same-directory workspaces.
|
||||
|
||||
// On desktop the explorer is pinned open; on narrow layouts it must be toggled.
|
||||
// Open it either way, then select the requested tab.
|
||||
async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise<void> {
|
||||
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
|
||||
if (await openButton.isVisible().catch(() => false)) {
|
||||
await openButton.click();
|
||||
}
|
||||
await page.getByTestId(`explorer-tab-${tab}`).click();
|
||||
}
|
||||
|
||||
async function createSecondWorkspaceOnSameDir(
|
||||
seeded: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
title,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create second workspace for ${seeded.projectId}`);
|
||||
}
|
||||
// Both workspaces back the same on-disk checkout.
|
||||
return created.workspace.id;
|
||||
}
|
||||
|
||||
test.describe("Same-directory workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" });
|
||||
|
||||
try {
|
||||
const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Second view");
|
||||
|
||||
// Seed an uncommitted change directly in the shared checkout. Because the
|
||||
// file browser and git diff read the directory, both workspaces must see
|
||||
// it — neither owns the directory state.
|
||||
await writeFile(
|
||||
path.join(seeded.workspaceDirectory, "SHARED_CHANGE.md"),
|
||||
"# shared change\n",
|
||||
);
|
||||
|
||||
// Make the write authoritative on the daemon before the UI reads it. The
|
||||
// git status/diff is otherwise refreshed by a debounced filesystem watcher,
|
||||
// and a loaded CI host can lag that debounce past the assertion window —
|
||||
// the source of this spec's flakiness. Forcing a refresh (the same path as
|
||||
// the UI's manual refresh) recomputes the snapshot and diff now, so the
|
||||
// first subscribe on mount already includes SHARED_CHANGE.md.
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// Workspace A: the new file shows in both the file browser and the git
|
||||
// changes view.
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await openExplorerTab(page, "files");
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME change is visible. The right
|
||||
// sidebar content does not differ between the two views.
|
||||
await gotoWorkspace(page, secondWorkspaceId);
|
||||
await openExplorerTab(page, "files");
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openExplorerTab(page, "changes");
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace state is independent: a terminal opened in A does not appear in B", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "same-dir-independent-" });
|
||||
|
||||
try {
|
||||
const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Independent view");
|
||||
|
||||
// Open workspace A and materialize a terminal tab.
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
await clickFirstTerminalTab(page);
|
||||
|
||||
// Workspace B shares the directory but owns its own tabs: it has no
|
||||
// terminal tab, because the terminal belongs to A.
|
||||
await gotoWorkspace(page, secondWorkspaceId);
|
||||
await expectNoTerminalTabs(page);
|
||||
|
||||
// Back in A, the terminal is still there — B never absorbed it.
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await clickFirstTerminalTab(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
test("sidebar pins the local daemon host first", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildOpenProjectRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
removeCurrentHostFromSettings,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -121,7 +127,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
@@ -133,10 +139,10 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
@@ -144,10 +150,10 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("about"));
|
||||
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
@@ -158,7 +164,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
@@ -167,11 +173,11 @@ test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
|
||||
@@ -188,11 +194,11 @@ test.describe("Settings — compact master-detail", () => {
|
||||
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
@@ -206,7 +212,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await openSettingsHostSection(page, getServerId(), "host");
|
||||
await removeCurrentHostFromSettings(page);
|
||||
await closeCompactSettings(page);
|
||||
|
||||
@@ -54,10 +54,12 @@ test.describe("Settings toggle tab regression", () => {
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-b-${Date.now()}`,
|
||||
});
|
||||
|
||||
@@ -74,7 +76,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
@@ -95,23 +97,25 @@ test.describe("Settings toggle tab regression", () => {
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-b-${Date.now()}`,
|
||||
});
|
||||
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
|
||||
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user