mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5cce8561 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd | ||
|
|
f4efdbded7 | ||
|
|
c976e2e5a5 | ||
|
|
b2bb512b31 | ||
|
|
067937bace | ||
|
|
d615d3d424 | ||
|
|
fbe5005aa1 | ||
|
|
e998e0a082 | ||
|
|
ae42b0afb0 | ||
|
|
829cc5e173 | ||
|
|
48384cafdf | ||
|
|
2c7b38bc7e | ||
|
|
222d45a2fc | ||
|
|
373da70694 | ||
|
|
c3e1e084a0 | ||
|
|
9978988e35 | ||
|
|
8768500fc0 | ||
|
|
89073d4c5c | ||
|
|
ec43e90670 | ||
|
|
897a0abb1a | ||
|
|
c4771ca467 | ||
|
|
05bfffad41 | ||
|
|
faee1cd952 | ||
|
|
ba4595d4e8 | ||
|
|
34c9fd03c9 | ||
|
|
9a3f3a0dc6 | ||
|
|
d6861f81e5 | ||
|
|
3054f80050 | ||
|
|
6d37f7fd9f | ||
|
|
94ab368e4e | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd | ||
|
|
7f7e60bcbb | ||
|
|
8b6e9447f4 | ||
|
|
83f9fba209 | ||
|
|
b2ce2bcb83 | ||
|
|
135a3b4c9e | ||
|
|
2c8e8a8268 | ||
|
|
ee7949ae25 | ||
|
|
d636abd7a4 | ||
|
|
e9d32a17d6 | ||
|
|
0fd1db5748 | ||
|
|
9696f42264 | ||
|
|
3cc4ae2866 | ||
|
|
0e965bcd7a | ||
|
|
2f3272490e | ||
|
|
7c1958f5b0 | ||
|
|
ed9b51f94e | ||
|
|
5d70ab2ab8 | ||
|
|
dc682adb2a | ||
|
|
92b92a81c8 | ||
|
|
bb4763b38c | ||
|
|
047f40e623 | ||
|
|
1e4ba65c6d | ||
|
|
6830c46e9a | ||
|
|
c7db3c5a8c | ||
|
|
a8a8da0472 | ||
|
|
6ce6f4c961 | ||
|
|
72e3d6957a | ||
|
|
46529146ac | ||
|
|
4f710d9930 | ||
|
|
0f20e6dfe4 | ||
|
|
0e8a71a5fe | ||
|
|
a78949d1d4 | ||
|
|
64b1a62ed3 | ||
|
|
0eac75be7d | ||
|
|
7557992dff | ||
|
|
4251575950 | ||
|
|
f9fb992dcb | ||
|
|
5ae0c5b83b | ||
|
|
4be34f05ef | ||
|
|
7d74916f8e | ||
|
|
80b4387bc6 | ||
|
|
326a37cc89 | ||
|
|
26e0273acb | ||
|
|
cb90806045 | ||
|
|
d8dd189b94 | ||
|
|
0474c3e0af | ||
|
|
c5c0536b46 | ||
|
|
b21c004ffe | ||
|
|
6215e08efe | ||
|
|
c3bcad687d | ||
|
|
174055a63c | ||
|
|
0aca3b605a | ||
|
|
0c5f472da1 | ||
|
|
13979e9c38 | ||
|
|
77c5c8f17e | ||
|
|
836f1a9c23 | ||
|
|
fdb5540a94 | ||
|
|
799c91b39d | ||
|
|
d63132a37d | ||
|
|
cdc669fde6 | ||
|
|
d1a53255fc | ||
|
|
d1b705a0cd | ||
|
|
0d7760e68b | ||
|
|
03a54a171d | ||
|
|
d0e1edca7f | ||
|
|
2d7733cd65 | ||
|
|
a7ce602c83 | ||
|
|
fa93c4290e | ||
|
|
192c517835 | ||
|
|
f22a37e613 | ||
|
|
772ff8d6f3 | ||
|
|
0110302b6f | ||
|
|
4006f974fb | ||
|
|
6f2195904b | ||
|
|
33a52d0ae7 | ||
|
|
16d932651c | ||
|
|
d7c7044dfc | ||
|
|
b8e24677e1 | ||
|
|
1f3bb0239b | ||
|
|
f7e68e32d1 | ||
|
|
94fc4e5d49 | ||
|
|
7bcf167862 | ||
|
|
8598b6aab9 | ||
|
|
ae1cf642bb | ||
|
|
2ee0191ea1 | ||
|
|
f3917173a9 | ||
|
|
92504cd525 | ||
|
|
726067b437 | ||
|
|
ec4e81ce0f | ||
|
|
bc385b5930 | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f | ||
|
|
433e67b18b | ||
|
|
d311797653 | ||
|
|
0b3d82a469 | ||
|
|
509a3e17bd | ||
|
|
19bd6e5909 | ||
|
|
16bfff8e0f | ||
|
|
fdf3b4b47f | ||
|
|
96ea6698d7 | ||
|
|
85cd969321 | ||
|
|
c172076bff | ||
|
|
613cbbe9ef | ||
|
|
da8c1b5c94 | ||
|
|
18aaec2772 | ||
|
|
158525624f | ||
|
|
47171b419b | ||
|
|
f809651876 |
@@ -52,7 +52,7 @@ app:
|
||||
sdk:
|
||||
- "packages/plugin/**"
|
||||
- "plugin-examples/**"
|
||||
- "public-docs/plugins/v0.8/**"
|
||||
- "public-docs/plugins/**"
|
||||
- "packages/client/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
|
||||
+76
-29
@@ -52,7 +52,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
@@ -120,6 +120,12 @@ jobs:
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Install website test browsers
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Verify website mockups
|
||||
run: npm run test:e2e --workspace=@getpaseo/website
|
||||
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||
@@ -174,11 +180,42 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -201,12 +238,35 @@ jobs:
|
||||
|
||||
- name: Install virtual display
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Verify Windows daemon process control
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
|
||||
|
||||
- name: Run desktop lifecycle E2E (Linux)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
|
||||
|
||||
- name: Upload desktop lifecycle screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-lifecycle
|
||||
path: ${{ runner.temp }}/desktop-lifecycle
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run desktop lifecycle E2E (Windows)
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run desktop renderer E2E
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
|
||||
@@ -226,23 +286,6 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
desktop-tests-windows:
|
||||
name: desktop-tests (windows-latest)
|
||||
needs: changes
|
||||
@@ -296,7 +339,7 @@ jobs:
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
@@ -304,9 +347,6 @@ jobs:
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Typecheck plugin examples
|
||||
run: npm run typecheck --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
@@ -355,6 +395,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -440,14 +483,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
# The local runner builds its dependencies and shards its own E2E files.
|
||||
- name: Run CLI E2E tests
|
||||
run: npm run test:local --workspace=@getpaseo/cli
|
||||
|
||||
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
|
||||
# shortest E2E shard, after its runner has built the server stack.
|
||||
- name: Run CLI source tests
|
||||
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/cli
|
||||
|
||||
cli-tests-2:
|
||||
name: cli-tests (shard 2/3)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Desktop Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
concurrency:
|
||||
group: desktop-packages-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install package smoke dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
|
||||
- name: Build Linux desktop artifacts
|
||||
# Smoke package contents and installation without spending minutes on xz compression.
|
||||
run: |
|
||||
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Smoke installed artifacts with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
if unshare --user --map-root-user --net true; then
|
||||
echo "Expected the launching user's namespace probe to be denied"
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y ./packages/desktop/release/*.deb
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
|
||||
|
||||
- name: Smoke sandbox-preserving portable launches
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
unshare --user --map-root-user --net true
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
|
||||
|
||||
- name: Smoke installed RPM with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
sudo dpkg --remove paseo
|
||||
# Ubuntu supplies the runtime libraries under Debian package names.
|
||||
# RPM still performs the real payload installation and postinstall.
|
||||
sudo rpm --install --nodeps packages/desktop/release/*.rpm
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -102,8 +102,10 @@ jobs:
|
||||
include:
|
||||
- runner: macos-14
|
||||
electron_arch: arm64
|
||||
node_heap_mb: 4096
|
||||
- runner: macos-15-intel
|
||||
electron_arch: x64
|
||||
node_heap_mb: 8192
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -161,6 +163,10 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
|
||||
# the last module in the web bundle. Node's default heap is sized from
|
||||
# runner RAM, which is not enough on the Intel runner.
|
||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
@@ -185,16 +191,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -261,7 +259,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
@@ -278,7 +276,7 @@ jobs:
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-linux-x64
|
||||
@@ -294,16 +292,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -400,16 +390,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -51,7 +30,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/build-daemon-web-ui.mjs"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,8 +12,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -110,8 +89,6 @@ jobs:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
|
||||
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
+163
-63
@@ -1,91 +1,191 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
|
||||
|
||||
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
|
||||
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
|
||||
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589))
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650))
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765))
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861))
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956))
|
||||
- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756))
|
||||
- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958))
|
||||
- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942))
|
||||
- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971))
|
||||
- Added `openExternalUrl()` and `<ExternalLink>` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972))
|
||||
- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972))
|
||||
- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger))
|
||||
- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork))
|
||||
- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675))
|
||||
- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati))
|
||||
- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736))
|
||||
- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598))
|
||||
- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844))
|
||||
- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575))
|
||||
|
||||
### Improved
|
||||
|
||||
- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742))
|
||||
- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676))
|
||||
- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007))
|
||||
- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863))
|
||||
- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863))
|
||||
- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958))
|
||||
- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838))
|
||||
- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442))
|
||||
- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844))
|
||||
- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839))
|
||||
- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824))
|
||||
- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902))
|
||||
- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946))
|
||||
- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973))
|
||||
- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013))
|
||||
- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758))
|
||||
- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl))
|
||||
- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447))
|
||||
- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575))
|
||||
- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926))
|
||||
- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756))
|
||||
- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991))
|
||||
- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862))
|
||||
- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596))
|
||||
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
|
||||
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
|
||||
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
|
||||
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
|
||||
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
|
||||
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
|
||||
- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861))
|
||||
- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925))
|
||||
- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898))
|
||||
|
||||
## 0.8.0 - 2026-09-10
|
||||
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
|
||||
|
||||
### Added
|
||||
|
||||
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
|
||||
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
|
||||
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
|
||||
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
|
||||
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
|
||||
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
|
||||
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
|
||||
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
|
||||
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
|
||||
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
|
||||
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
|
||||
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
|
||||
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
|
||||
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
|
||||
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
|
||||
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
|
||||
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
|
||||
@@ -106,9 +106,14 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
## Release branches
|
||||
|
||||
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
|
||||
When the user says "this goes to next", create or
|
||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **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.
|
||||
@@ -120,6 +125,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
|
||||
- **Always run typecheck and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||
- `npm run build:client` — rebuild protocol and client declarations.
|
||||
|
||||
+61
-99
@@ -1,142 +1,104 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thank you for taking the time to contribute to Paseo.
|
||||
This guide is here to save us both time and help you find a useful way to contribute.
|
||||
|
||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
**Paseo has a lean, opinionated core built to be extended.**
|
||||
|
||||
Given Paseo's scope, contributing to it 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.
|
||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
## Report bugs in GitHub issues or Discord
|
||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
||||
|
||||
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
|
||||
## Build a plugin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Feature requests opened as issues will get closed automatically
|
||||
Most specialized workflows and integrations are better served by plugins.
|
||||
|
||||
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
|
||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
||||
|
||||
You may submit a PR to fix a bug, please read the PR guidelines below.
|
||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
||||
|
||||
## Product discussions go in GitHub Discussions or Discord
|
||||
## Report bugs
|
||||
|
||||
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
|
||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
||||
|
||||
Include:
|
||||
|
||||
- What you did, what you expected, and what happened.
|
||||
- Reproduction steps, Paseo version, and platform.
|
||||
- Relevant logs, screenshots, or a recording.
|
||||
|
||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
||||
|
||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
||||
|
||||
## Discuss workflows before proposing features
|
||||
|
||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
||||
|
||||
Explain:
|
||||
|
||||
- What are you trying to do?
|
||||
- How do you do it today?
|
||||
- Where does Paseo get in the way?
|
||||
- What would the flow look like if it worked for you?
|
||||
- What would a better workflow look like?
|
||||
|
||||
"Can you build X" is not useful because it doesn't tell me about your problem.
|
||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
||||
|
||||
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
|
||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
||||
|
||||
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
|
||||
## If you still want to submit a pull request
|
||||
|
||||
## Pull requests
|
||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
||||
|
||||
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
|
||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
||||
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
||||
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
||||
|
||||
Here is the criteria I use to decide:
|
||||
If you choose to submit a PR, these are the basics to cover.
|
||||
|
||||
✅ Likely to be accepted
|
||||
### What I look for in a core feature
|
||||
|
||||
- Fits the product vision
|
||||
- One focused change
|
||||
- Links the bug it fixes, or the discussion behind it if there is one
|
||||
- Addresses bot reviews
|
||||
- Explains the problem you're solving
|
||||
- QA evidence
|
||||
- Automated tests
|
||||
- Screenshots or video for UI changes, on every affected platform
|
||||
- Says which platforms you tested and which you didn't
|
||||
- Maintainer edits enabled
|
||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
||||
|
||||
⛔️ Will be rejected
|
||||
### Keep the scope small
|
||||
|
||||
- Bundles unrelated changes
|
||||
- Fails checks
|
||||
- Ignores bot reviews
|
||||
- Takes a feature or the design in a direction I don't want
|
||||
- No QA evidence
|
||||
- No tests
|
||||
- Clearly fully AI-generated PR
|
||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
||||
|
||||
### What to expect
|
||||
### QA evidence
|
||||
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
||||
|
||||
## QA evidence
|
||||
Explain the problem and link the relevant bug report or discussion. Include:
|
||||
|
||||
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
|
||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
||||
- The commands you ran and their output.
|
||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
||||
- The platforms you tested and any affected platforms you could not test.
|
||||
|
||||
Pull requests without evidence will be closed.
|
||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
||||
|
||||
What's accepted:
|
||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
||||
|
||||
- The shell commands you ran, pasted with their output
|
||||
- The tests you added and their results
|
||||
- Before and after screenshots
|
||||
- A video of the whole interaction
|
||||
- Logs, requests, responses
|
||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
||||
|
||||
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
|
||||
### What happens to your contribution
|
||||
|
||||
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
|
||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
||||
|
||||
## I want to become a maintainer
|
||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
||||
|
||||
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
|
||||
## Help the community
|
||||
|
||||
## FAQ
|
||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
||||
|
||||
### I'm blocked, where do I go?
|
||||
|
||||
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
|
||||
|
||||
### Do you prefer Github or Discord?
|
||||
|
||||
I am more active on Discord for general questions.
|
||||
|
||||
I prefer Github for bugs because the template helps collect information more efficiently.
|
||||
|
||||
### Why was my feature request closed?
|
||||
|
||||
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
|
||||
|
||||
### Why didn't my PR get a detailed review?
|
||||
|
||||
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
|
||||
|
||||
### How long until someone looks at my pull request?
|
||||
|
||||
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
|
||||
|
||||
### Why do you want tests and a video for a small change?
|
||||
|
||||
Because it's faster and safer than me checking out and running every PR myself to test it manually.
|
||||
|
||||
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
|
||||
|
||||
### I only have one platform, can I still contribute?
|
||||
|
||||
Yes. Test what you have, and say what you didn't test.
|
||||
|
||||
### Can I use an agent to write my contribution?
|
||||
|
||||
Yes, as long as you understand what you're submitting and you tested it yourself.
|
||||
|
||||
### Is there a roadmap I can read?
|
||||
|
||||
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
|
||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -50,10 +50,9 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
|
||||
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
|
||||
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
@@ -194,6 +193,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
@@ -51,7 +51,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
@@ -81,7 +81,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
// ... test ...
|
||||
|
||||
|
||||
+33
-10
@@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
|
||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
|
||||
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
|
||||
resume repairs that drift through the provider's native unarchive hook; history resume does not.
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration. Other archived agents stay archived.
|
||||
|
||||
Opening an agent is a navigation choice, independent of whether its details are cached. The
|
||||
layout retains that choice across reload while the panel fetches the agent from the daemon.
|
||||
Once the daemon reports the agent active, its tab follows normal archive propagation again.
|
||||
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
|
||||
not own selection or release the explicit open.
|
||||
|
||||
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
|
||||
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||
@@ -148,6 +160,17 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
```
|
||||
|
||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
||||
@@ -28,6 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
@@ -120,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
## F-Droid / source-only Android builds
|
||||
|
||||
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
|
||||
|
||||
+62
-7
@@ -54,6 +54,15 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
Desktop and CLI import server capabilities through explicit package subpaths such as
|
||||
`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export
|
||||
loads daemon bootstrap eagerly, even when a caller only uses a path or process helper.
|
||||
Keep process-management and configuration dependencies independent of bootstrap and
|
||||
WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules;
|
||||
`messages` imports and re-exports them. This prevents every supervising process from
|
||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
||||
these dependency trees with tree-shaking disabled to match unbundled production imports.
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
@@ -96,7 +105,8 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
@@ -144,7 +154,7 @@ traffic. Workspace assignments stay on the workspace directory sequence.
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
@@ -238,11 +248,22 @@ Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
There is no dedicated welcome message; the server emits one initial `status.server_info` after
|
||||
accepting hello. Modern clients negotiate `owned_subscriptions` against
|
||||
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
|
||||
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
|
||||
correlates the initial reply by request ID, then routes updates by subscription ID.
|
||||
|
||||
A logical Session can contain several physical sockets. Each socket owns its capabilities,
|
||||
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
|
||||
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
|
||||
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
|
||||
its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
@@ -296,7 +317,7 @@ messages or transfers.
|
||||
|
||||
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
||||
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
||||
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
|
||||
|
||||
Example: adding a new enum value
|
||||
|
||||
@@ -307,6 +328,36 @@ Example: adding a new enum value
|
||||
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
||||
```
|
||||
|
||||
### Creation ownership
|
||||
|
||||
Creation is owned by the daemon across socket lifetimes. A workspace request can include
|
||||
its initial agent and prompt. Workspace readiness is published before provider startup;
|
||||
the app keeps its existing disk-ready navigation and observes the remaining creation.
|
||||
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
|
||||
identities, not ready workspace records.
|
||||
|
||||
The creation journal (`server/creation/`) owns identity and execution for both legacy and
|
||||
modern creation RPCs. Requesting progress never selects a different journal. Existing
|
||||
agent receipts are imported at this boundary; message delivery receipts remain separate.
|
||||
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
|
||||
key. Reconnect subscribes to that key; retries
|
||||
join active work or reuse committed stages. A persisted resource alone cannot prove
|
||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
||||
receipt return an unknown outcome instead of being repeated.
|
||||
|
||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
||||
sequence after the client process disappears. Explicitly requested IDs or receipts
|
||||
that an old host cannot honor produce an unsupported error. Keep these adapters inside
|
||||
the client package, as an exception to the default no-fallback feature policy.
|
||||
|
||||
Creation executes through the existing Session capabilities. Connection-owned delivery
|
||||
controls observation only: detaching a socket or cleaning up its Session does not cancel
|
||||
accepted creation. Updates require an explicit subscription and go only to that socket;
|
||||
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
|
||||
existing response contract.
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
@@ -417,12 +468,16 @@ $PASEO_HOME/
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
├── paseo.pid # Supervisor identity and published bound endpoint
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
|
||||
|
||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
|
||||
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see:
|
||||
by retrying until the frame appears;
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination;
|
||||
- parked guests remain capturable with Chromium background throttling enabled;
|
||||
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
|
||||
host composer;
|
||||
- the automation group loads the compiled production keyboard boundary and guest
|
||||
@@ -73,11 +72,17 @@ The harness writes PNG evidence and `results.json` to:
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
|
||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||
is usually saved as 2560x1600.
|
||||
|
||||
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
|
||||
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
|
||||
and resumes animation after restoring the window. Its artifacts include the screenshot
|
||||
and animation measurements. Full-page content correctness remains separately tracked in
|
||||
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
|
||||
|
||||
## Mechanism
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
@@ -96,8 +101,11 @@ plane stays below the overlay plane regardless of body insertion order; menus ke
|
||||
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
|
||||
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
invalidates before each attempt, and retries known first-frame failures within the
|
||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
There is no renderer prep/restore handshake or lifetime background-throttling override.
|
||||
Screenshot capture temporarily enables frame production inside the shared serialized queue,
|
||||
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
|
||||
The browser tool takes one viewport frame through Electron's frame subscription and releases the
|
||||
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
|
||||
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
|
||||
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
|
||||
metrics and screenshot clip.
|
||||
|
||||
@@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
|
||||
+10
-8
@@ -64,8 +64,8 @@ $PASEO_HOME/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
├── plugins/
|
||||
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||
│ ├── sources.json # Managed kind and Git acquisition remote
|
||||
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -254,11 +254,13 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
|
||||
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
|
||||
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
|
||||
Paseo writes it atomically. An update creates and validates a new version directory before changing
|
||||
the configured directory path; successful activation removes the old version.
|
||||
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
|
||||
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
|
||||
remote. Installed revision, package/subdirectory identity and ownership root come from retained
|
||||
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
|
||||
explains their authority. An update prepares a new directory before replacing the active path and
|
||||
removing the previous version. Old record fields are accepted at the store boundary and ignored;
|
||||
there is no startup migration or persistent update policy.
|
||||
|
||||
### Profile lists
|
||||
|
||||
@@ -488,7 +490,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
|
||||
+5
-4
@@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values
|
||||
|
||||
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:
|
||||
Enable it in persistent configuration:
|
||||
|
||||
```bash
|
||||
paseo daemon start --web-ui
|
||||
paseo daemon config set features.webUi.enabled true
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon run
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
@@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
`--host` overrides the environment variable.
|
||||
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ 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.
|
||||
|
||||
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -30,7 +30,10 @@ never read or modify that width.
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Callers request the content and never choose the shell.
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
even for a different PR; moving or reordering it remains the user's choice.
|
||||
Callers request the content and never choose the shell.
|
||||
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
||||
routes later presses to the working diff through the shared diff preference.
|
||||
|
||||
@@ -45,7 +48,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
|
||||
focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
@@ -57,8 +60,11 @@ lifecycle.
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
|
||||
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
|
||||
tab content. There is no hidden side-pane lifecycle.
|
||||
|
||||
Placement intent still controls existing tabs:
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ state.
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## Error Recovery
|
||||
|
||||
The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
|
||||
remounts the router at `/open-project`, preserving saved workspace layouts and
|
||||
the remembered selection. Do not move the boundary inside the root layout:
|
||||
catching a render error there unmounts the navigator, so the recovery button
|
||||
cannot reliably dispatch a navigation reset. Starting recovery at `/` would
|
||||
restore the same workspace and could repeat the crash.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+59
-5
@@ -58,7 +58,10 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
hit-testing starts fresh in that window. If its children use gesture-handler,
|
||||
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
|
||||
the app's root does not span Android windows. React context alone does not
|
||||
establish a native gesture root. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
@@ -134,13 +137,38 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
|
||||
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
|
||||
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
|
||||
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
|
||||
The optional third child is an overlay that moves with the surface. New workspace
|
||||
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
|
||||
space or translate the composer themselves. Put the dismiss surface behind content
|
||||
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
|
||||
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
|
||||
MOVE events before its native scroll view reaches touch slop. Streaming can hide
|
||||
this failure by delaying the responder grant. Native scroll views retain their own
|
||||
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
|
||||
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
|
||||
need `none`); otherwise their empty padding blocks hit testing of that sibling.
|
||||
The shared screen header uses the same dismiss surface. Keep the
|
||||
header escape available when a capped draft clips all content above it, since
|
||||
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
|
||||
and the stream keeps its existing flick-to-dismiss gesture.
|
||||
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
||||
signal because the stock iOS Reanimated value changes at move start, while
|
||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
|
||||
|
||||
`KeyboardDock` always keeps the chat surface at full height and wraps it in
|
||||
`ComposerDock` always keeps the surface at full height and wraps it in
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
@@ -154,7 +182,33 @@ layout. Preserve the translated dock's history scroll range with a far-end
|
||||
content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
The translated dock's height is not the composer's available height. Bound the
|
||||
composer against the stationary space below the header inside
|
||||
`composer/dock`, including its controls and attachments. Reserve the
|
||||
keyboard destination at move start on the UI thread. Waiting for a settled JS
|
||||
update lets a long draft disappear behind the header during opening. This is a
|
||||
boundary-time layout change, not an animated layout prop. Keep the input's
|
||||
native intrinsic sizing and internal scrolling. Keep the editing input's
|
||||
`defaultValue` current on every edit: Fabric re-measures the Android input from
|
||||
the JS text whenever its frame changed, so a stale prop inflates or collapses
|
||||
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
|
||||
|
||||
Closing the keyboard moves the composer; it does not enlarge its capacity. The
|
||||
viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
Yoga split the shortage between them: rows vanished behind the composer while
|
||||
the input lost lines at the same time. Keep that separation inside the dock,
|
||||
shared by every phone host. The centered tablet form caps the complete block
|
||||
for header clearance, but reserves the composer's own capped height before
|
||||
shrinking the setup scroll view. The form must not take editing space from the
|
||||
composer.
|
||||
|
||||
Move the stream and composer together through `ComposerDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
|
||||
+10
-3
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
@@ -49,6 +52,10 @@ Provider controls remain provider-native; see [providers.md](providers.md).
|
||||
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
|
||||
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
|
||||
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
|
||||
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
|
||||
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
|
||||
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
|
||||
for the lifetime of a draft and reuses the first message ID after failures.
|
||||
|
||||
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
|
||||
@@ -179,6 +179,10 @@ its own.
|
||||
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
||||
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
||||
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
||||
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
@@ -187,6 +191,10 @@ its own.
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
|
||||
@@ -116,6 +116,8 @@ definition, no longer eligible to begin.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM
|
||||
nodes carry no Fabric ownership; the native file keeps the chat live.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -47,6 +47,33 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
|
||||
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
|
||||
to test an installed production build. It never submits a chat message.
|
||||
|
||||
`npm run test:e2e:composer-keyboard:android` preserves the chat control and
|
||||
keyboard regression flow, then runs the same growth, retained-height, bounds, and
|
||||
hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
does not prove that native scrolling receives touches.
|
||||
Artifacts are grouped by host under
|
||||
`.dev/agent-device-artifacts/composer-keyboard-android`. Configure its daemon,
|
||||
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
```bash
|
||||
adb shell pm disable-user --user 0 com.google.android.gm
|
||||
adb shell pm disable-user --user 0 com.google.android.calendar
|
||||
```
|
||||
|
||||
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
|
||||
|
||||
## Maestro compatibility
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+76
-58
@@ -1,10 +1,10 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -68,7 +68,7 @@ runtime modules, so consumers do not install these packages when adding the plug
|
||||
```
|
||||
|
||||
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
|
||||
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
|
||||
[requirements contract](../public-docs/plugins/reference.md#requirements), including legacy
|
||||
manifests and prerelease matching.
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
@@ -103,10 +103,36 @@ paseo plugin update --all
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
|
||||
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
|
||||
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
|
||||
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
|
||||
npm package.json checked against package-lock.json provide the current revision. Keep the complete
|
||||
npm dependency tree and lockfile after staging activation. No install selector governs updates.
|
||||
|
||||
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
|
||||
disabled during acquisition; manifest preparation owns required commands before validation.
|
||||
|
||||
List responses retain the closed `source: directory | git` enum. The optional `installation`
|
||||
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
|
||||
emitted. Installation uses `features.pluginSourceInstallation`; reviewed preview/apply uses
|
||||
`features.pluginSourceUpdates`, gated by the SDK and CLI entry. The old immediate-update request
|
||||
returns update-client guidance. Basic management remains independently available.
|
||||
|
||||
Preview returns exact targets and expected installed identity/root/revision. Apply validates those
|
||||
values, acquires the displayed artifact or commit, then rechecks state before activation. No review
|
||||
session is stored in the daemon. PluginService owns activation/recovery and independent bulk results.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
@@ -162,10 +188,10 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
|
||||
[host UI contract](../public-docs/plugins/reference.md#host-ui).
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
### SDK import boundaries
|
||||
|
||||
@@ -206,7 +232,7 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
|
||||
browser globals are not available across the plugin. Put sanctioned web-only APIs in
|
||||
`client/web.ts`, declare only the globals that module uses, gate each export with
|
||||
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
|
||||
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
|
||||
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
@@ -253,6 +279,11 @@ selected host's existing connection; switching the screen's host changes both `u
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
|
||||
@@ -261,7 +292,7 @@ catalog is complete.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
@@ -278,15 +309,16 @@ when moved between hosts. Explorer configuration can create workspace-context pa
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
|
||||
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
|
||||
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
|
||||
@@ -334,7 +366,7 @@ and `plugin-examples/provider-acp-transformer`.
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
daemon-appended plugin items. The direct example includes both sides. The renderer-only
|
||||
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
@@ -344,55 +376,37 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
## Contribute buttons
|
||||
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
|
||||
owns the author API and placement rules. Keep presentation policy in this module so another
|
||||
placement can reuse behavior without copying registration or action state.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
|
||||
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
built-in projection stay unchanged. The app transforms each source item while building the render
|
||||
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
|
||||
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
|
||||
model, for both fetched history and live events, before native Markdown splitting and Overview
|
||||
tool grouping. Assistant callbacks receive the accumulated source text, never display fragments.
|
||||
Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`.
|
||||
Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so
|
||||
streaming updates preserve mounted component identity.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
@@ -489,7 +503,7 @@ drops the optional presentation fields.
|
||||
|
||||
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
|
||||
The host settings shell owns navigation and layout; plugin content must not add another page
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/reference.md#settings-screens)
|
||||
and `plugin-examples/settings` for the named UI components and persistence API.
|
||||
|
||||
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
|
||||
@@ -497,6 +511,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m
|
||||
so updates and reloads retain values. Settings-change notifications must not enter the catalog
|
||||
reload path: that path disposes the plugin and would destroy open drafts after every save.
|
||||
|
||||
`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current
|
||||
`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The
|
||||
subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry.
|
||||
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
@@ -520,7 +538,7 @@ stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/migration.md).
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||
|
||||
+74
-53
@@ -1,81 +1,102 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
|
||||
|
||||
## What is Paseo
|
||||
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
## Agents are the focus
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
|
||||
|
||||
## Core philosophy
|
||||
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
## A low floor and a high ceiling
|
||||
|
||||
## How it works
|
||||
### Easy to start
|
||||
|
||||
### Projects and workspaces
|
||||
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
|
||||
|
||||
### Inside a workspace
|
||||
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
### Room to build your own setup
|
||||
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
|
||||
|
||||
### The daemon
|
||||
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
|
||||
|
||||
This architecture means:
|
||||
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
|
||||
|
||||
## Target user
|
||||
## Freedom, ownership, and privacy
|
||||
|
||||
Anyone who builds software:
|
||||
Paseo's flexibility depends on keeping control with the user:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
|
||||
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
|
||||
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
|
||||
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
|
||||
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
|
||||
|
||||
## What compounds over time
|
||||
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
## A lean core that keeps improving
|
||||
|
||||
## Strategic bets
|
||||
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
|
||||
|
||||
## Current state (May 2026)
|
||||
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
|
||||
|
||||
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
|
||||
|
||||
## Extensibility and composition
|
||||
|
||||
Plugins are a central part of how Paseo gains capability.
|
||||
|
||||
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
|
||||
|
||||
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
|
||||
|
||||
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
|
||||
|
||||
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
|
||||
|
||||
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
|
||||
|
||||
## How Paseo develops
|
||||
|
||||
Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
|
||||
|
||||
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
|
||||
|
||||
- Can people discover and understand it?
|
||||
- Does it work well in their actual workflows?
|
||||
- What friction or missing capability appears with repeated use?
|
||||
- Does the next addition improve the shared experience or belong in a plugin?
|
||||
|
||||
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
|
||||
|
||||
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
|
||||
|
||||
## An ecosystem people can build on
|
||||
|
||||
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
|
||||
|
||||
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
|
||||
|
||||
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
|
||||
|
||||
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
|
||||
|
||||
@@ -38,6 +38,40 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
The client package advertises the protocol behavior it implements. Add each new capability to
|
||||
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
||||
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
||||
automation, or explicit overrides. A schema accepting a message does not establish support for
|
||||
its delivery semantics.
|
||||
|
||||
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
|
||||
behavior once at its connection boundary. App workflows use the same observation interface on both.
|
||||
|
||||
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
|
||||
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
|
||||
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
|
||||
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
|
||||
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
|
||||
or multiplexing emulation are introduced.
|
||||
|
||||
Preserve established workflows when changing delivery internals. Independent filters and quiet
|
||||
connections require a capable daemon; opening the app, reading history and using terminals do not.
|
||||
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
|
||||
|
||||
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
|
||||
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
|
||||
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
||||
for parsing compatibility; modern requests cannot select their subscription ID.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
+21
-5
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
@@ -49,6 +58,13 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
|
||||
|
||||
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
|
||||
|
||||
ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe
|
||||
process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses
|
||||
`cursor/list_available_models` because switching models during discovery writes its saved CLI
|
||||
preferences and selection history. Cursor versions without that extension must be updated. Kimi
|
||||
still probes model selections in its own shim. The initial session supplies modes and the current
|
||||
model; it does not override the model list returned by a resolver.
|
||||
|
||||
### 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.
|
||||
|
||||
+34
-1
@@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## Release branch discipline
|
||||
|
||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
||||
release. This applies to both beta and stable releases.
|
||||
|
||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
||||
PR, and keep that destination through delivery.
|
||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
||||
Pass CI and merge without squashing away the individual PR commits needed for
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
### Hotfix from a release tag
|
||||
|
||||
If `main` contains changes you do not want to release, branch from the affected
|
||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
||||
use the normal release flow with it as the explicit source, choosing a new patch
|
||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
||||
`next`, preserving newer development and version changes there. This is a
|
||||
short-lived hotfix branch, not another maintained release track.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
@@ -64,7 +94,7 @@ release push as the changelog and version commit.
|
||||
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
@@ -162,6 +192,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `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`
|
||||
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
||||
- 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.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
|
||||
@@ -486,6 +517,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
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.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
|
||||
@@ -26,6 +26,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **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.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
||||
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
|
||||
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
|
||||
|
||||
|
||||
+8
-4
@@ -107,14 +107,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
|
||||
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
|
||||
|
||||
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
|
||||
- the `paseo://app/` renderer mounts into `#root`;
|
||||
- the sandboxed preload exposes the desktop bridge;
|
||||
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||
- the bundled CLI can query that daemon and run a terminal command.
|
||||
|
||||
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
|
||||
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
|
||||
|
||||
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
|
||||
|
||||
Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence.
|
||||
|
||||
To exercise the smoke locally on Linux:
|
||||
|
||||
@@ -128,7 +132,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
|
||||
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
|
||||
|
||||
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
|
||||
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
|
||||
|
||||
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
|
||||
|
||||
@@ -200,7 +204,7 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths.
|
||||
|
||||
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
|
||||
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
+64
-14
@@ -5,8 +5,10 @@ Agent chat delivery has two paths:
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||
|
||||
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||
authority and repopulates those rows when an agent resumes.
|
||||
The daemon retains projected items in memory. Each source event advances the stream sequence,
|
||||
then replaces the previous tool state or merges into the current text item. Intermediate payloads
|
||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||
and rebuilds the projection when an agent resumes.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -67,15 +69,34 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
||||
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
||||
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
|
||||
page.
|
||||
Focus alone does not mutate timeline state; the tail response is compared with the local
|
||||
Opening, reconnecting, and returning from app background establish the current timeline through
|
||||
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
|
||||
Focus alone does not mutate timeline state; the response is compared with the local
|
||||
authoritative range first.
|
||||
|
||||
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
|
||||
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
|
||||
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
|
||||
one; disconnect and backgrounding keep it.
|
||||
|
||||
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
|
||||
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
|
||||
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
|
||||
|
||||
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
|
||||
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
|
||||
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
|
||||
@@ -88,6 +109,25 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
|
||||
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
|
||||
history start; a response requested from a pre-replacement range is stale and is discarded.
|
||||
|
||||
A plan approval keeps the original proposal's tool-call identity through resolution and provider
|
||||
history replay. The pending approval UI can hide that tool from presentation, but the client model
|
||||
must retain its position. Creating a new history card on rejection places it after the prompt that
|
||||
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
||||
|
||||
## Provider child history
|
||||
|
||||
Child transcripts use the same projected-page reconciliation as the main conversation. The client
|
||||
retains rendered items and sequence cursors, never a second cache of source events. Pagination
|
||||
uses the projected display anchor; live updates advance the source cursor without moving tools.
|
||||
|
||||
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
|
||||
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
|
||||
an update-host notice for that pane.
|
||||
Older clients retain child names/status and receive an upgrade message when opening a child
|
||||
conversation. This gate affects only the child transcript; it does not gate the connection,
|
||||
main conversation, or current-context forks. A legacy `canonical` root request still receives
|
||||
projected items.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
@@ -95,6 +135,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
|
||||
behind one interface. React supplies transport and projection operations without selecting a cache
|
||||
path or issuing a separate persistence notification.
|
||||
|
||||
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
|
||||
history response can arrive before the active-list response that omits it. Clearing history there
|
||||
would discard accepted rows while the viewed owner still considers them synchronized. Entity
|
||||
deletion clears the transcript; list membership changes do not.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
@@ -128,14 +173,19 @@ replica cache.
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
|
||||
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
|
||||
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
|
||||
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
|
||||
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
|
||||
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
|
||||
an evicted retained timeline displays its cached state immediately while authoritative catch-up
|
||||
advances it to the current tail.
|
||||
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
|
||||
enters that set the first time the user opens it and leaves when its tab closes or the session
|
||||
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
|
||||
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
|
||||
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
|
||||
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
|
||||
subscribing from layout spawns a provider session per historical tab at every launch and stamps
|
||||
every one of those workspaces as just used (see
|
||||
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
|
||||
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
|
||||
does not starve background recovery. Split panes catch up together. Hidden chats update the
|
||||
replica; on web their retained presentation stays suspended until revealed, on native it keeps
|
||||
rendering. Revealing a chat reads the current store and preserves its local UI state.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+55
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36268,6 +36268,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
@@ -37261,12 +37262,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37513,10 +37514,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37527,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37538,6 +37539,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
@@ -37770,7 +37772,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38666,7 +38668,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38695,14 +38697,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38712,8 +38714,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38735,7 +38737,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38762,7 +38764,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38980,16 +38982,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39002,6 +39004,7 @@
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lru-cache": "^11.5.1",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"openai": "^6.44.0",
|
||||
@@ -39012,6 +39015,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"semver": "^7.8.5",
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -39024,6 +39028,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
@@ -39468,6 +39473,17 @@
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@types/markdown-it": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
@@ -39706,6 +39722,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
@@ -39867,7 +39895,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,6 +5,7 @@ const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storag
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -141,6 +142,7 @@ export default {
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
|
||||
@@ -36,14 +36,18 @@ import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
recordPanelToasts,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openSessions,
|
||||
clickSessionRow,
|
||||
expectWorkspaceTabVisible,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -428,6 +432,18 @@ async function expectInterruptedTurnOrderAfterReconnect(
|
||||
}
|
||||
}
|
||||
|
||||
async function visitEvictionWorkspaces(
|
||||
page: Page,
|
||||
agents: readonly { agentId: string }[],
|
||||
): Promise<void> {
|
||||
for (const [index, agent] of agents.entries()) {
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, `Workspace eviction ${index + 1}.`);
|
||||
await expectWorkspaceTabVisible(page, agent.agentId);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
@@ -443,50 +459,76 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
|
||||
title: `Workspace eviction ${index + 1}`,
|
||||
title: `Workspace eviction ${index + 1}.`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const prompt = "Keep this hidden image prompt before its streaming output.";
|
||||
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
|
||||
const openAgentIds = [target.agentId, ...evictionAgents.map((agent) => agent.agentId)];
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
// Open every chat before holding output. Adding chats replaces the timeline
|
||||
// subscription and retires the IDs stamped on already-buffered frames.
|
||||
await visitEvictionWorkspaces(page, evictionAgents);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, target.agentId);
|
||||
await expectComposerVisible(page);
|
||||
const subscriptionRequests = gate.getClientRequestCount(
|
||||
"agent.timeline.set_subscription.request",
|
||||
);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
gate.setAgentStreamItemSuppressed("user_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents([
|
||||
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
|
||||
]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await gate.waitForHeldAgentStreamEvent("turn_started");
|
||||
// Finish production before navigation so passing cannot depend on late
|
||||
// chunks arriving after the final workspace switch.
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
|
||||
// Navigate inside the app: a document reload discards the retained deck and
|
||||
// adds startup history fetches, so it cannot prove eviction/resume behavior.
|
||||
await visitEvictionWorkspaces(page, evictionAgents);
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
|
||||
subscriptionRequests,
|
||||
);
|
||||
gate.releaseHeldAgentStreamEvent("turn_started");
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
|
||||
const responseStart = page.getByText("Cycle 1", { exact: true });
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(responseStart).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, responseStart);
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
// Open chats stay subscribed when their workspace view is evicted. Returning
|
||||
// uses that live timeline without another resume check or startup tail fetch.
|
||||
expect(rememberTimelineRequestCounts(gate, target.agentId)).toEqual(requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.setAgentStreamItemSuppressed("user_message", false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
@@ -868,7 +910,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one canonical prompt when the provider echoes before accepting", async ({
|
||||
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -889,6 +931,20 @@ test.describe("Agent message submission", () => {
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
for (const [index, nextPrompt] of [
|
||||
"emit 1 coalesced agent stream updates for the second turn.",
|
||||
"emit 1 coalesced agent stream updates for the third turn.",
|
||||
].entries()) {
|
||||
await submitMessage(page, nextPrompt);
|
||||
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
|
||||
}
|
||||
await fillComposerDraft(page, "Keep this unsent draft.");
|
||||
await composerLocator(page).blur();
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(3);
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -908,16 +964,12 @@ test.describe("Agent message submission", () => {
|
||||
await submitMessage(page, prompt);
|
||||
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
const marker = `late-provider-identity-${Date.now()}`;
|
||||
await submittedPrompt.evaluate((element, value) => {
|
||||
element.dataset.lateProviderIdentity = value;
|
||||
}, marker);
|
||||
|
||||
await submittedPrompt.hover();
|
||||
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
} finally {
|
||||
@@ -1114,8 +1166,11 @@ test.describe("Agent message submission", () => {
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = 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 { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -88,18 +88,12 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const evidence = { addedNodes: 0, characterDataMutations: 0, removedNodes: 0 };
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
if (record.type === "characterData") evidence.characterDataMutations += 1;
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
withSpokenTimeline,
|
||||
captureSpokenTimeline,
|
||||
reloadSpokenTimeline,
|
||||
expectSpokenTimelinePrompt,
|
||||
sendSpokenTimelinePrompt,
|
||||
expectStableHistoryStartGutter,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
@@ -12,7 +16,6 @@ import {
|
||||
holdOlderHistoryPages,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
@@ -26,42 +29,28 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
test("shows spoken words without voice instructions in history, live updates and after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const historyText = "Please check the voice history.";
|
||||
const liveText = "Now check the live voice message.";
|
||||
await withSpokenTimeline(historyText, async (agent) => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
await expectSpokenTimelinePrompt(page, historyText);
|
||||
await sendSpokenTimelinePrompt(agent, liveText);
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
await captureSpokenTimeline(page, testInfo, "desktop");
|
||||
await reloadSpokenTimeline(page);
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
await captureSpokenTimeline(page, testInfo, "compact");
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
test("keeps the history-start gutter and visible position stable through the final page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
@@ -73,8 +62,10 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
} finally {
|
||||
@@ -96,7 +87,9 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
@@ -137,23 +130,6 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps visible history anchored when live output grows during a prepend", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -181,7 +157,8 @@ test.describe("Agent timeline pagination", () => {
|
||||
|
||||
test("finishes loading an older page while live output continues", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
// The live turn streams for thirty minutes, so it is still running at every assertion.
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
@@ -193,8 +170,11 @@ test.describe("Agent timeline pagination", () => {
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
);
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText("walking through").first()).toBeAttached();
|
||||
history.releasePage(1);
|
||||
|
||||
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
|
||||
const running = await agent.client.fetchAgents({ scope: "active" });
|
||||
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
|
||||
@@ -205,28 +185,7 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues one loading operation while older pages still leave history start exposed", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
@@ -235,12 +194,12 @@ test.describe("Agent timeline pagination", () => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const loading = await rememberOlderHistoryLoadingOperation(page);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelineAtHistoryStart(page);
|
||||
await expectSameOlderHistoryLoadingOperation(page, loading);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await forkInFlightTurnToNewTab(page);
|
||||
await expectChatHistoryAttachment(page);
|
||||
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
|
||||
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: sourceAgentTitle }).click();
|
||||
await expectLiveAssistantText(page, visibleAfterFork);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import type { BrowserContext, Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
}
|
||||
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
for (const [tag, text] of [
|
||||
["strong", "strong prose"],
|
||||
["em", "emphasized prose"],
|
||||
|
||||
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
|
||||
await openChangedFilesOverview(page);
|
||||
await test.step("reopening the overview expands folders again", async () => {
|
||||
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
|
||||
});
|
||||
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
|
||||
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
|
||||
});
|
||||
});
|
||||
|
||||
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
|
||||
const committed = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, committed, 90_000);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, committed);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -954,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
test("changes diff waits for configured fonts, then applies code typography changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await holdBrowserFontLoads(page);
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
|
||||
await test.step("commit no geometry before the configured fonts are ready", async () => {
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
const before = await readDiffTypographyGeometry(page);
|
||||
|
||||
await changeCodeTypographyFromSettings(page, {
|
||||
@@ -977,63 +1013,31 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await holdBrowserFontLoads(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
|
||||
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await cancelInlineReview(page);
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect
|
||||
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
@@ -1143,36 +1147,36 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||
context,
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-selection").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-line").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
|
||||
await test.step("copy the selected characters with the keyboard", async () => {
|
||||
await copyDiffSelectionWithKeyboard(page);
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
await test.step("copy the preserved selection after resizing", async () => {
|
||||
await resizeDiffViewportHeight(page, 960);
|
||||
await copyFromChangedLineMenu(page, "Copy");
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
});
|
||||
await test.step("copy the complete source line", async () => {
|
||||
await copyFromChangedLineMenu(page, "Copy line");
|
||||
await expectClipboardText(page, "ABCDEFGHIJ");
|
||||
});
|
||||
await test.step("dismiss a new selection without starting a review", async () => {
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
await rightClickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
@@ -1186,19 +1190,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||
context,
|
||||
page,
|
||||
@@ -1393,14 +1384,17 @@ async function setOpenChangesPresentation(
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds every font load in the document until released; later loads pass through. */
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
const originalLoad = fontSet.load.bind(fontSet);
|
||||
const pending: Array<() => void> = [];
|
||||
let released = false;
|
||||
Object.defineProperty(fontSet, "load", {
|
||||
configurable: true,
|
||||
value(font: string, text?: string) {
|
||||
if (released) return originalLoad(font, text);
|
||||
return new Promise<FontFace[]>((resolve, reject) => {
|
||||
pending.push(() => {
|
||||
originalLoad(font, text).then(resolve, reject);
|
||||
@@ -1410,6 +1404,7 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
Object.assign(window, {
|
||||
__releasePaseoDiffFontLoads() {
|
||||
released = true;
|
||||
for (const release of pending.splice(0)) release();
|
||||
},
|
||||
});
|
||||
@@ -1659,12 +1654,72 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
|
||||
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
// Reload at the compact size: panel selection starts at the center on a cold
|
||||
// mount, independently of any desktop sidebar used earlier in the test.
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
async function openChangedFilesOverview(page: Page): Promise<void> {
|
||||
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
|
||||
await expect(jumpToFile).toBeVisible();
|
||||
await jumpToFile.click();
|
||||
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
|
||||
}
|
||||
|
||||
async function expectOverviewReopensExpanded(
|
||||
page: Page,
|
||||
folderName: string,
|
||||
childFileName: string,
|
||||
): Promise<void> {
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
const tree = changesTree(page);
|
||||
const folder = tree
|
||||
.getByRole("button")
|
||||
.filter({ has: page.getByText(folderName, { exact: true }) });
|
||||
const child = tree.getByText(childFileName, { exact: true });
|
||||
await expect(folder).toBeVisible();
|
||||
await expect(child).toBeVisible();
|
||||
await folder.click();
|
||||
await expect(child).toHaveCount(0);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openChangedFilesOverview(page);
|
||||
await expect(child).toBeVisible();
|
||||
}
|
||||
|
||||
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
|
||||
}
|
||||
|
||||
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
|
||||
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
|
||||
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(
|
||||
@@ -1820,6 +1875,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||
}
|
||||
|
||||
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(content);
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
}
|
||||
|
||||
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
}
|
||||
|
||||
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
await rightClickFirstChangedLine(page);
|
||||
const item = page.getByRole("menuitem", { name: action, exact: true });
|
||||
await expect(item).toBeEnabled();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
async function expectClipboardText(page: Page, text: string): Promise<void> {
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
|
||||
}
|
||||
|
||||
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const previousViewport = page.viewportSize()!;
|
||||
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
|
||||
await page.setViewportSize({ width: previousViewport.width, height });
|
||||
await expect(canvas).toHaveCSS(
|
||||
"height",
|
||||
`${previousHeight + height - previousViewport.height}px`,
|
||||
);
|
||||
}
|
||||
|
||||
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import {
|
||||
composerLocator,
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_RESPONSE = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].join("\n\n");
|
||||
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
|
||||
}).join("\n\n");
|
||||
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openChatFind(page: Page) {
|
||||
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
}
|
||||
async function searchChat(page: Page, text: string) {
|
||||
await openChatFind(page);
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectHighlight(page: Page, text: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const entries = Array.from(CSS.highlights.entries()).filter(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const texts: string[] = [];
|
||||
for (const [, highlight] of entries) {
|
||||
for (const range of highlight) texts.push(range.toString());
|
||||
}
|
||||
return texts;
|
||||
}),
|
||||
)
|
||||
.toEqual([text]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return false;
|
||||
const rect = range.getBoundingClientRect();
|
||||
const row = range.startContainer.parentElement?.closest(
|
||||
'[data-testid="agent-chat-scroll"]',
|
||||
);
|
||||
const viewport = row?.getBoundingClientRect();
|
||||
return (
|
||||
!!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
async function nextMatch(page: Page) {
|
||||
await query(page).press("Enter");
|
||||
}
|
||||
async function previousMatch(page: Page) {
|
||||
await query(page).press("Shift+Enter");
|
||||
}
|
||||
async function expectFindStatus(page: Page, search: string, matches: string) {
|
||||
await query(page).fill(search);
|
||||
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
|
||||
}
|
||||
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText(matches);
|
||||
await expectHighlight(page, highlighted);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")),
|
||||
),
|
||||
)
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-",
|
||||
title: "Chat Find",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 45; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
const jumps = await trackPromptJumpRequests(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
await searchChat(page, "hello world");
|
||||
await expectHighlight(page, "hello world");
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
|
||||
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText("2 of 90");
|
||||
await expectHighlight(page, "hello world");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await query(page).fill("hello & world");
|
||||
await expectHighlight(page, "hello & world");
|
||||
await query(page).fill("a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
await query(page).fill("Prompt number 0");
|
||||
await expectHighlight(page, "Prompt number 0");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// The scope of the search is the whole chat, so the counter is the position across
|
||||
// every message that holds a hit, and Next walks off the end of one into the next.
|
||||
const ACROSS_PROMPT = "Find scope-needle in this chat";
|
||||
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
|
||||
|
||||
/** The test id of the message that holds the current match, so it can be watched crossing one. */
|
||||
async function highlightedMessage(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
}
|
||||
async function expectHighlightedMessage(page: Page, testId: string) {
|
||||
await expect.poll(() => highlightedMessage(page)).toBe(testId);
|
||||
}
|
||||
|
||||
test("counts and steps through every match in the chat, not just the selected message", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-across-",
|
||||
title: "Chat Find across messages",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
// One hit in the prompt and two in the reply: three across the chat.
|
||||
await searchChat(page, "scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await expectNextMatch(page, "3 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
|
||||
await expectNextMatch(page, "1 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("3 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-cancel-",
|
||||
title: "Chat Find cancellation",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "hidden-destination");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await query(page).fill("first cell second cell");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
gate.holdNextServerMessage("agent.timeline.search.response");
|
||||
await query(page).fill("hello world");
|
||||
await expect(status(page)).toHaveText("Searching…");
|
||||
await gate.waitForHeldServerMessage("agent.timeline.search.response");
|
||||
await closeFind(page);
|
||||
gate.releaseHeldServerMessage("agent.timeline.search.response");
|
||||
await searchChat(page, "a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows a disconnected search failure and recovers with Retry", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-retry-",
|
||||
title: "Chat Find retry",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await searchChat(page, "hello world");
|
||||
await expect(status(page)).toHaveText("Failed");
|
||||
await expect(
|
||||
page.getByText("Could not search this chat. Check the host connection and retry.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
gate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expectHighlight(page, "hello world");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
...agent,
|
||||
replyFinished: async () => {
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
// The only hit is in the first paragraph, which is no longer the row that grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.replyFinished();
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two Markdown blocks, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
await expectNextMatch(page, "2 of 2", "pair-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a chat whose newest reply is a single message far taller than the viewport,
|
||||
* behind enough older turns that the oldest one is not hydrated yet.
|
||||
*/
|
||||
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-virtualized-",
|
||||
title: "Chat Find virtualized message",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Every block row of the selected message must be mounted for Find to see it, whatever
|
||||
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
|
||||
// from the count and Next walks off to the next message instead.
|
||||
test("counts every occurrence of a virtualized message and steps between them", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatWithVirtualizedTallMessage(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
await expectFindStatus(page, "span-needle", "1 of 12");
|
||||
await expectHighlight(page, "span-needle");
|
||||
await expectNextMatch(page, "2 of 12", "span-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tail = "oversized-tail-needle";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-cap-",
|
||||
title: "Chat Find long message",
|
||||
initialPrompt: "Render a long response",
|
||||
// One paragraph, so the needle is past the render cap of the row that holds it.
|
||||
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message).toBeVisible();
|
||||
await expect(message).not.toContainText(tail);
|
||||
await searchChat(page, tail);
|
||||
await expectHighlight(page, tail);
|
||||
await closeFind(page);
|
||||
await expect(message).not.toContainText(tail);
|
||||
await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-composer-",
|
||||
title: "Chat Find from composer",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await searchFromComposerWithoutLosingDraft(page, shortcut);
|
||||
await refocusFindAndReplaceQuery(page, shortcut);
|
||||
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
|
||||
await closeFind(page);
|
||||
await expectCommandCenterOwnsFindShortcut(page, shortcut);
|
||||
await fillComposerDraft(page, "Draft after closing Find");
|
||||
await expectComposerDraft(page, "Draft after closing Find");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
test.describe("macOS", () => {
|
||||
test.use({ userAgent: MAC_USER_AGENT });
|
||||
|
||||
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Meta+f", testInfo);
|
||||
});
|
||||
|
||||
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
|
||||
test("leaves Control+f to the composer", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-mac-control-f-",
|
||||
title: "Chat Find macOS Control+f",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await fillComposerDraft(page, "Keep this draft");
|
||||
await expectComposerFocused(page);
|
||||
await recordComposerKeydown(page);
|
||||
await page.keyboard.press("Control+f");
|
||||
await expect
|
||||
.poll(() => recordedComposerFindKeydown(page))
|
||||
.toEqual([{ key: "f", defaultPrevented: false }]);
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectComposerFocused(page);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedKeydown {
|
||||
key: string;
|
||||
defaultPrevented: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Find listens on `document` in the capture phase, so the composer only ever
|
||||
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
|
||||
* on the composer the evidence of who owns the key.
|
||||
*/
|
||||
async function recordComposerKeydown(page: Page) {
|
||||
await composerLocator(page).evaluate((element) => {
|
||||
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
|
||||
store.__findKeydown = [];
|
||||
element.addEventListener("keydown", (event) => {
|
||||
store.__findKeydown.push({
|
||||
key: (event as KeyboardEvent).key,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
|
||||
const recorded = await page.evaluate(
|
||||
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
|
||||
);
|
||||
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
|
||||
}
|
||||
|
||||
async function openFindFromComposer(page: Page, shortcut: string) {
|
||||
await page.keyboard.press(shortcut);
|
||||
await expect(query(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function searchFromComposerWithoutLosingDraft(page: Page, shortcut: string) {
|
||||
await fillComposerDraft(page, "Keep this draft");
|
||||
await expectComposerFocused(page);
|
||||
await openFindFromComposer(page, shortcut);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
await query(page).fill("hello world");
|
||||
await expectHighlight(page, "hello world");
|
||||
}
|
||||
|
||||
async function refocusFindAndReplaceQuery(page: Page, shortcut: string) {
|
||||
await composerLocator(page).click();
|
||||
await expectComposerFocused(page);
|
||||
await openFindFromComposer(page, shortcut);
|
||||
await expect(query(page)).toHaveValue("hello world");
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
await page.keyboard.type("a.b");
|
||||
await expect(query(page)).toHaveValue("a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
}
|
||||
|
||||
async function expectCommandCenterOwnsFindShortcut(page: Page, shortcut: string) {
|
||||
await openCommandCenter(page);
|
||||
await page.keyboard.press(shortcut);
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible();
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await closeCommandCenter(page);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
@@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const prompt = "Explain in several paragraphs.";
|
||||
await withStreamingMarkdownOutline(async (agent) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await expectChatOutlinePrompts(page, 2);
|
||||
|
||||
await expectReadingStreamedMarkdown(page, prompt);
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
await clickChatOutlineRowEdge(page, 2);
|
||||
await expectTimelinePromptLandedBelowTop(page, prompt);
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an existing agent's only supported thinking level", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-controls-single-thinking-",
|
||||
title: "Single thinking level",
|
||||
model: "max-only-thinking-stream",
|
||||
thinkingOptionId: "max",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
|
||||
).toHaveAccessibleName("Select thinking option (Max)");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
@@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => {
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await test.step("find an agent and open its workspace tab", async () => {
|
||||
const agentPanel = await openCommandCenter(page);
|
||||
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { attachFileFromMenu, controlFileUploadCompletion } from "../support/helpers/composer";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -52,17 +53,29 @@ const TEST_JSON = {
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
test("selected file shows a loading attachment until upload is acknowledged", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const upload = await controlFileUploadCompletion(page);
|
||||
const workspace = await withWorkspace({ prefix: "attach-upload-pending-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
upload.hold();
|
||||
await attachFileFromMenu(page, TEST_JSON);
|
||||
await upload.waitForUpload();
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
const pending = page.getByTestId("composer-pending-file-attachment");
|
||||
await expect(pending).toContainText(TEST_JSON.name);
|
||||
await expect(pending.getByRole("progressbar")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toHaveCount(0);
|
||||
|
||||
upload.complete();
|
||||
await expect(pending).toHaveCount(0);
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText(TEST_JSON.name);
|
||||
await expectComposerEditable(page);
|
||||
});
|
||||
|
||||
test("compact Plus menu aligns attachment rows with its sheet title", async ({
|
||||
@@ -101,59 +114,27 @@ test.describe("Composer attachments", () => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
category: "attachments",
|
||||
issues: [{ title: "fix: attachment issue" }],
|
||||
prs: [{ title: "feat: attachment pull request", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
// Choose from the repository list. Newly created repositories are not
|
||||
// necessarily available in GitHub's separate text-search index yet.
|
||||
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
@@ -168,7 +149,7 @@ test.describe("Composer attachments", () => {
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
test("attaches, previews, and removes an image before dropping a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -178,38 +159,25 @@ test.describe("Composer attachments", () => {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await test.step("open the available attachment choices", async () => {
|
||||
await openAttachmentMenu(page);
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
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");
|
||||
await test.step("remove the image and attach a dropped file", async () => {
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
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 }) => {
|
||||
@@ -238,23 +206,6 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => {
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
const draftTabs = page
|
||||
.locator('[data-testid^="workspace-tab-draft"]')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Composer diff stat",
|
||||
repo: { withRemote: true },
|
||||
repo: {
|
||||
withRemote: true,
|
||||
// Exclude the local remote before the daemon starts observing this checkout.
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||
|
||||
@@ -65,6 +65,19 @@ test("blank composer lines remain present and keep their measured height", async
|
||||
const blankLines = "\n\n\n\n\n";
|
||||
const collapsedHeight = await composerHeight(page);
|
||||
|
||||
await test.step("remeasure the current draft when the window narrows", async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 1200 });
|
||||
const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8);
|
||||
await composer.fill(text);
|
||||
await expect(composer).toHaveValue(text);
|
||||
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
|
||||
const wideHeight = await composerHeight(page);
|
||||
await page.setViewportSize({ width: 480, height: 1200 });
|
||||
await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight);
|
||||
await expect(composer).toHaveValue(text);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
await test.step("grow the composer with blank lines followed by text", async () => {
|
||||
await composer.fill(`${blankLines}x`);
|
||||
await expect(composer).toHaveValue(`${blankLines}x`);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { createCreationScenario } from "../support/helpers/creation";
|
||||
import { expect } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
|
||||
creation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm(isolation);
|
||||
await creation.submitRepeatedly("Create");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
});
|
||||
}
|
||||
|
||||
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("retrying the app's agent creation returns the same agent for every attempt", async ({
|
||||
creation,
|
||||
agentRetries,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start exactly one agent for this prompt.");
|
||||
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Name this new agent from its first prompt.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectAgentTitle("Name this new agent from its first prompt.");
|
||||
});
|
||||
|
||||
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
|
||||
creation,
|
||||
promptRetry,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
promptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Deliver this initial prompt once.");
|
||||
await promptRetry.waitForDeliveredPrompt();
|
||||
await promptRetry.disconnectAndReconnect();
|
||||
await promptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a remounted draft reconciles its original creation after reconnect", async ({
|
||||
creation,
|
||||
promptRetry,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
promptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Deliver this initial prompt once.");
|
||||
await promptRetry.waitForDeliveredPrompt();
|
||||
await creation.evictAndReturnToDraft();
|
||||
await creation.expectPromptVisible("Deliver this initial prompt once.");
|
||||
await promptRetry.disconnectAndReconnect();
|
||||
await promptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
|
||||
creation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("separate drafts can intentionally create two agents in the same workspace", async ({
|
||||
creation,
|
||||
delayedCreation,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start the first agent.");
|
||||
await creation.expectPromptVisible("Start the first agent.");
|
||||
await delayedCreation.waitForDelayedCreatedStatus();
|
||||
await creation.startAnotherDraft();
|
||||
await creation.submitPrompt("Start the second agent.");
|
||||
await creation.expectPromptVisible("Start the second agent.");
|
||||
await delayedCreation.waitForDelayedCreatedStatus();
|
||||
delayedCreation.release();
|
||||
await creation.expectAgentCount(2);
|
||||
});
|
||||
|
||||
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
|
||||
creation,
|
||||
startup,
|
||||
delayedCreation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
delayedCreation.expectSingleWorkspaceIntent();
|
||||
delayedCreation.release();
|
||||
await startup.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ suffix: "", prepare: async () => {} },
|
||||
{
|
||||
suffix: " after remount",
|
||||
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.evictAndReturnToDraft(),
|
||||
},
|
||||
]) {
|
||||
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { metroTest, expect } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { withProjectOwnership } from "../support/helpers/project-ownership";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation";
|
||||
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
|
||||
|
||||
for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
let daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
const test = metroTest.extend<{
|
||||
client: DaemonClient;
|
||||
gate: Awaited<ReturnType<typeof holdLegacyAgentCreation>>;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
}>({
|
||||
gate: async ({ page }, provide) => {
|
||||
const gate = await holdLegacyAgentCreation(page, daemon.port);
|
||||
await provide(gate);
|
||||
gate.release();
|
||||
},
|
||||
client: async ({}, provide) => {
|
||||
const client = withProjectOwnership(
|
||||
await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "legacy-creation",
|
||||
port: daemon.port,
|
||||
}),
|
||||
);
|
||||
await provide(client);
|
||||
await client.close();
|
||||
},
|
||||
repo: async ({}, provide) => {
|
||||
const repo = await createTempGitRepo("legacy-creation-");
|
||||
await provide(repo);
|
||||
await repo.cleanup();
|
||||
},
|
||||
project: async ({}, provide) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port });
|
||||
await provide(project);
|
||||
await project.cleanup();
|
||||
},
|
||||
});
|
||||
test.describe(`published daemon ${version}`, () => {
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(120_000);
|
||||
daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, {
|
||||
publishedVersion: version,
|
||||
});
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await daemon?.close();
|
||||
});
|
||||
|
||||
for (const kind of ["workspace", "agent"] as const) {
|
||||
test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({
|
||||
repo,
|
||||
client,
|
||||
}) => {
|
||||
const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`;
|
||||
const features = client.getLastServerInfoMessage()?.features;
|
||||
expect(features?.creationLifecycle).not.toBe(true);
|
||||
expect(features?.workspaceRequestReceipts).not.toBe(true);
|
||||
expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0");
|
||||
|
||||
const create = await prepareCreation(client, kind, repo.path, prompt);
|
||||
const [agent, duplicate] = await Promise.all([create(), create()]);
|
||||
expect(duplicate.id).toBe(agent.id);
|
||||
expect(agent.title).toBe(prompt);
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
const agents = await client.fetchAgents();
|
||||
expect(agents.entries).toHaveLength(1);
|
||||
expect(agents.entries[0]!.agent.title).toBe(prompt);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id);
|
||||
expect(
|
||||
timeline.entries.filter(
|
||||
({ item }) => item.type === "user_message" && item.text === prompt,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
|
||||
page,
|
||||
project,
|
||||
client,
|
||||
gate,
|
||||
}) => {
|
||||
await openOldHost(page, project.workspaceId);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Create");
|
||||
await gate.waitForRequest();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(0);
|
||||
const workspaces = (await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
await expectPromptOnce(client, first.id, prompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
|
||||
).toHaveText(prompt);
|
||||
|
||||
await createAgentTabFromMenu(page);
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
)!.agent;
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
await client.waitForFinish(second.id, 20_000);
|
||||
await expectPromptOnce(client, second.id, secondPrompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
|
||||
).toHaveText(secondPrompt);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ visible: true })
|
||||
.filter({ hasText: secondPrompt }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
|
||||
});
|
||||
|
||||
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId);
|
||||
expect(
|
||||
timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function openOldHost(page: Page, workspaceId: string) {
|
||||
const host = buildSeededHost({
|
||||
serverId: daemon.serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
|
||||
}
|
||||
|
||||
async function prepareCreation(
|
||||
client: DaemonClient,
|
||||
kind: "workspace" | "agent",
|
||||
cwd: string,
|
||||
prompt: string,
|
||||
) {
|
||||
const input = { provider: "mock", cwd, initialPrompt: prompt };
|
||||
if (kind === "workspace")
|
||||
return async () => {
|
||||
const result = await client.createWorkspace({
|
||||
idempotencyKey: "workspace-intent",
|
||||
source: { kind: "worktree", cwd, worktreeSlug: "created-once" },
|
||||
agent: input,
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.workspace?.workspaceDirectory).not.toBe(cwd);
|
||||
return result.agent!;
|
||||
};
|
||||
const workspace = await client.createWorkspace({
|
||||
idempotencyKey: "empty-workspace",
|
||||
source: { kind: "directory", path: cwd },
|
||||
});
|
||||
return () =>
|
||||
client.createAgent({
|
||||
...input,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
idempotencyKey: "agent-intent",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
send();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
openDaemonOverview,
|
||||
restartDaemonInSettings,
|
||||
expectDaemonRestartComplete,
|
||||
readWorkerPid,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
const supervisor = daemon.getPid();
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(daemon.getPid()).toBe(supervisor);
|
||||
await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import {
|
||||
clickNewChat,
|
||||
gotoWorkspace,
|
||||
pressDirectNewTabShortcut,
|
||||
} from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const focusedPaneChild = page
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
|
||||
const launcher = visibleNewTabPanel(page);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import {
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
@@ -93,132 +93,221 @@ async function dragChipTo(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
function collectConsoleErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
e2eWorkerClient,
|
||||
}, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
|
||||
const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
|
||||
let agentId = "";
|
||||
|
||||
try {
|
||||
await test.step("empty workspace seeds a draft in the main pane", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
await test.step("choose Agent from the empty workspace launcher", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
});
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
const agent = await e2eWorkerClient.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Explorer Placement Agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
// Keep the agent streaming through the drag so pane contents are live,
|
||||
// matching the reporter's "spinners were still going" environment.
|
||||
initialPrompt: "stream please",
|
||||
});
|
||||
agentId = agent.id;
|
||||
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(
|
||||
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
await closeSeededDraftInMainPane(page);
|
||||
await expect(mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await testInfo.attach("before-drag", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("split the agent out of the main pane, leaving it empty", async () => {
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
const mainBox = await paneBoxContainingChip(page, chip);
|
||||
// Land inside the left 15% edge band of the main pane content.
|
||||
await dragChipTo(page, chip, {
|
||||
x: mainBox.x + mainBox.width * 0.06,
|
||||
y: mainBox.y + mainBox.height * 0.6,
|
||||
});
|
||||
await testInfo.attach("after-split", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
// JS main thread alive?
|
||||
const pong = await page.evaluate("1 + 1");
|
||||
expect(pong).toBe(2);
|
||||
|
||||
// Pointer interaction alive? Selecting the agent chip must still work.
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await testInfo.attach("after-center-drop", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Explorer Placement Agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
// Keep the agent streaming through the drag so pane contents are live,
|
||||
// matching the reporter's "spinners were still going" environment.
|
||||
initialPrompt: "stream please",
|
||||
});
|
||||
agentId = agent.id;
|
||||
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(
|
||||
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
// The explorer toggle must still respond.
|
||||
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
|
||||
await testInfo.attach("after-interactions", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
await closeSeededDraftInMainPane(page);
|
||||
await expect(
|
||||
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
|
||||
).toHaveCount(1);
|
||||
await testInfo.attach("before-drag", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("split the agent out of the main pane, leaving it empty", async () => {
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
const mainBox = await paneBoxContainingChip(page, chip);
|
||||
// Land inside the left 15% edge band of the main pane content.
|
||||
await dragChipTo(page, chip, {
|
||||
x: mainBox.x + mainBox.width * 0.06,
|
||||
y: mainBox.y + mainBox.height * 0.6,
|
||||
});
|
||||
await testInfo.attach("after-split", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
// JS main thread alive?
|
||||
const pong = await page.evaluate("1 + 1");
|
||||
expect(pong).toBe(2);
|
||||
|
||||
// Pointer interaction alive? Selecting the agent chip must still work.
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await testInfo.attach("after-center-drop", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
// The explorer toggle must still respond.
|
||||
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
|
||||
await testInfo.attach("after-interactions", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await testInfo.attach("console-errors", {
|
||||
body: JSON.stringify(consoleErrors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
await testInfo.attach("console-errors", {
|
||||
body: JSON.stringify(consoleErrors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function closeOnlyDraft(page: Page): Promise<void> {
|
||||
await draftTabChip(page).hover();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
}
|
||||
|
||||
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, draftTabChip(page), {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectNewLauncher(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(draftTabChip(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
|
||||
await expectNewLauncher(page);
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-panel")
|
||||
.getByRole("button", { name: "Agent", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
// Explorer cannot replace the ordinary workspace canvas, including on restore.
|
||||
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await moveOnlyDraftIntoRightSplit(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
});
|
||||
|
||||
test("visible Explorer does not replace the last ordinary workspace pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await moveOnlyDraftIntoRightSplit(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
});
|
||||
|
||||
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const otherWorkspace = await withWorkspace({ prefix: "uncorrupted-explorer-" });
|
||||
await gotoWorkspace(page, otherWorkspace.workspaceId);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expectNewLauncher(page);
|
||||
await seedCorruptedWorkspaceLayout(page, workspace.workspaceId);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
await gotoWorkspace(page, otherWorkspace.workspaceId);
|
||||
await expect(draftTabChip(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -111,21 +111,6 @@ test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const row = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByTestId(/^file-explorer-row-\d+$/)
|
||||
.first();
|
||||
const name = row.getByTestId(/-name$/);
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
await expect(name).toHaveCSS("opacity", "0.76");
|
||||
await row.hover();
|
||||
await expect(name).toHaveCSS("opacity", "1");
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
|
||||
@@ -82,121 +82,66 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Host appearance", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
};
|
||||
test("customizes a host badge and preserves its appearance after reload", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
};
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
|
||||
await test.step("rename the host and use its available sidebar width", async () => {
|
||||
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
|
||||
});
|
||||
|
||||
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await renameHostFromSettings(page, hostName);
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectHostBadgeName(page, { ...badge, hostName });
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
await expect(badge).toHaveText(hostName);
|
||||
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${badge.serverId}:${badge.workspaceId}`);
|
||||
const hostBadge = row.getByTestId(`host-badge-${badge.serverId}`);
|
||||
const [badgeBox, rowBox] = await Promise.all([hostBadge.boundingBox(), row.boundingBox()]);
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
scrollWidth: label.scrollWidth,
|
||||
const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
client: label.clientWidth,
|
||||
content: label.scrollWidth,
|
||||
}));
|
||||
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.client);
|
||||
});
|
||||
|
||||
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
};
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("preview a color and apply it to the sidebar badge", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostColor(page, "Teal");
|
||||
await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" });
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
|
||||
await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" });
|
||||
});
|
||||
|
||||
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
};
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("keep the icon-only preference through reload", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostBadgeDisplay(page, "Icon only");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeIconOnly(page, badge);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
await reloadPreservingHostRegistry(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
});
|
||||
|
||||
test("hiding one host leaves the other host's badge visible", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("hide this badge without hiding the other host", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostBadgeDisplay(page, "Hidden");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectNoHostBadge(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
});
|
||||
await expectNoHostBadge(page, { ...badge, hostName });
|
||||
await expectHostBadgeName(page, {
|
||||
serverId: twoHostSidebar.primaryServerId,
|
||||
workspaceId: twoHostSidebar.primaryWorkspaceId,
|
||||
hostName: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
});
|
||||
|
||||
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await chooseHostColor(page, "Amber");
|
||||
await chooseHostBadgeDisplay(page, "Icon only");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await reloadPreservingHostRegistry(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectHostBadgeIconOnly(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: "Build Box",
|
||||
});
|
||||
});
|
||||
|
||||
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await chooseHostColor(page, "Emerald");
|
||||
|
||||
await expectHostAppearancePreview(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
hostName: "Build Box",
|
||||
color: "emerald",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
|
||||
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
|
||||
|
||||
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
([key, bindingId, value]) => {
|
||||
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
|
||||
},
|
||||
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-step shortcut", () => {
|
||||
test("completes a chord whose second step is a bare key", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
|
||||
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
|
||||
test("completes a chord whose second step carries a modifier", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("Control+j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
// "Toggle command center" recorded as a two-key sequence. Both platform
|
||||
// bindings are overridden so the spec does not depend on the host OS.
|
||||
const COMMAND_CENTER_SEQUENCE = {
|
||||
"command-center-toggle-cmd-k-mac": "Cmd+K J",
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
|
||||
};
|
||||
|
||||
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
|
||||
const SEQUENCE_FINAL_KEY = "j";
|
||||
|
||||
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
|
||||
async function recordCommandCenterSequence(page: Page): Promise<void> {
|
||||
await page.addInitScript((overrides) => {
|
||||
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
|
||||
}, COMMAND_CENTER_SEQUENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the window and let the app render again, so the resize lands between
|
||||
* the two keys of the sequence rather than after them.
|
||||
*/
|
||||
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.waitForFunction((expected) => window.innerWidth === expected, width);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-key shortcut sequences", () => {
|
||||
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
test("a sequence completes when the window resizes between its keys", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await resizeWindowAndSettle(page, 1100);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -13,17 +13,13 @@ import {
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
expectTabTitleFits,
|
||||
terminalSurfaceLocator,
|
||||
} from "../support/helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
setupDeterministicPrompt,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
@@ -43,7 +39,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-empty-prompt",
|
||||
name: "Empty Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||
@@ -126,47 +122,40 @@ test.describe("Tab creation", () => {
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||
test("opens the menu, then creates independent New tabs without creating agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||
});
|
||||
|
||||
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await expect(newTabs.first()).toBeVisible();
|
||||
const countBefore = await newTabs.count();
|
||||
const draftCount = await countTabsOfKind(page, "draft");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||
await test.step("opening the plus menu leaves the current tabs intact", async () => {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(newTabs).toHaveCount(countBefore);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await test.step("two shortcuts open two independent launchers", async () => {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids).toEqual(expect.arrayContaining(firstIds));
|
||||
expect(await countTabsOfKind(page, "draft")).toBe(draftCount);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||
@@ -267,110 +256,6 @@ test.describe("Tab creation", () => {
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
|
||||
test("never shades the New tab button", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||
const scrollShades = await tabRow
|
||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||
.count();
|
||||
|
||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
// OSC title escape sequence propagation is inherently flaky — the terminal
|
||||
// must process the sequence, emit a title change event, and the tab bar
|
||||
// must re-render before the assertion deadline. Allow retries.
|
||||
test.describe.configure({ retries: 2 });
|
||||
|
||||
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",
|
||||
undefined,
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
}
|
||||
await terminalSurfaceLocator(page).pressSequentially(
|
||||
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import {
|
||||
closeModelPicker,
|
||||
expectModelSearchEmptyState,
|
||||
@@ -15,7 +18,7 @@ import {
|
||||
expectSearchResultsVirtualized,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
@@ -158,3 +161,111 @@ test.describe("Cross-provider model search", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) {
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Catalog provider",
|
||||
enabled: true,
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"),
|
||||
String(count),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd })).entries.find(
|
||||
(entry) => entry.provider === "gemini",
|
||||
)?.status,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("ready");
|
||||
}
|
||||
async function reloadSavedDraft(page: Page) {
|
||||
await page.evaluate(() =>
|
||||
localStorage.setItem(
|
||||
"@paseo:e2e-disable-default-seed-once",
|
||||
localStorage.getItem("@paseo:e2e-seed-nonce")!,
|
||||
),
|
||||
);
|
||||
await page.reload();
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
async function expectOneCatalogChoice(page: Page, label: string) {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await searchAllModels(page, label);
|
||||
await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1);
|
||||
await closeModelPicker(page);
|
||||
}
|
||||
|
||||
async function configureModelOverride(client: DaemonClient, cwd: string) {
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
gemini: {
|
||||
additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd: cwd })).entries.find(
|
||||
(entry) => entry.provider === "gemini",
|
||||
)?.models,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]);
|
||||
}
|
||||
|
||||
const catalogTest = test.extend<{ catalogClient: DaemonClient }>({
|
||||
catalogClient: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "catalog-models" });
|
||||
try {
|
||||
await provide(client);
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({ removeProviders: ["gemini"] });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
catalogTest(
|
||||
"New Agent and saved drafts stay usable with repeated runtime model rows",
|
||||
async ({ page, withWorkspace, catalogClient: client }, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "catalog-models-" });
|
||||
await test.step("open a draft with a one-row runtime catalog", async () => {
|
||||
await setRuntimeCatalog(client, 1, workspace.repoPath);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
await test.step("open New Agent after the provider publishes repeated model rows", async () => {
|
||||
await clickNewTerminal(page);
|
||||
await setRuntimeCatalog(client, 2, workspace.repoPath);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
|
||||
await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") });
|
||||
});
|
||||
await test.step("reopen the saved draft with the same catalog", async () => {
|
||||
await reloadSavedDraft(page);
|
||||
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
|
||||
});
|
||||
await test.step("retain configured model overrides when runtime IDs repeat", async () => {
|
||||
await configureModelOverride(client, workspace.repoPath);
|
||||
await reloadSavedDraft(page);
|
||||
await expectOneCatalogChoice(page, "Configured model");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expect, type Page } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/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, cwd: string): Promise<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "codex-mode-preferences",
|
||||
@@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { 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,
|
||||
startup,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false });
|
||||
try {
|
||||
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
await gotoAppShell(page);
|
||||
@@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
).toBeVisible();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page.getByText("Keep Codex full access selected globally.", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
expect(createAgentRequest.config).toMatchObject({
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
@@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
await expect
|
||||
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
|
||||
.toBe("full-access");
|
||||
await startup.fail();
|
||||
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
// Exercise each submit handler's navigation guard. Checkout isolation shares
|
||||
// those handlers; ordinary successful launches live in the creation journeys.
|
||||
test("chat worktree creation keeps the workspace chosen while it was pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "leave", "worktree");
|
||||
});
|
||||
|
||||
test("terminal creation keeps the workspace chosen while it was pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "terminal", "leave");
|
||||
});
|
||||
|
||||
test("empty creation keeps the workspace chosen while it was pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "empty", "leave");
|
||||
});
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
+55
-1
@@ -1,5 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
|
||||
import {
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
getTerminalBufferText,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -17,6 +24,19 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
test.setTimeout(120_000);
|
||||
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
|
||||
const observationSockets = new Set<unknown>();
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (
|
||||
frame.type === "session" &&
|
||||
frame.message.type === "fetch_agents_request" &&
|
||||
frame.message.subscribe
|
||||
)
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
port: daemon.port,
|
||||
@@ -79,6 +99,40 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
|
||||
history.expectRepeatedEntries();
|
||||
await history.expectOwnedTextRendered("Now I have a clearer picture.");
|
||||
expect(observationSockets.size).toBe(1);
|
||||
|
||||
await test.step("send a live turn after reconnecting the old daemon", async () => {
|
||||
await daemon.restart();
|
||||
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
|
||||
await expectComposerEditable(page);
|
||||
await submitMessage(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
await expectTimelinePromptVisible(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
// The browser and the seed client reconnect independently after the restart.
|
||||
await agent.client.connect();
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
|
||||
});
|
||||
|
||||
await test.step("open a terminal and receive its output", async () => {
|
||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||
expect(terminal.error).toBeNull();
|
||||
const terminalId = terminal.terminal!.id;
|
||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||
await focusTerminalSurface(page);
|
||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||
await expect
|
||||
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.toContain("compatibility-terminal-output");
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
@@ -0,0 +1,475 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function source(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openSource(page: Page, filename: string) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expect(source(page)).toBeVisible();
|
||||
}
|
||||
async function findInSource(page: Page, text: string) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill(text);
|
||||
}
|
||||
function querySelection(page: Page) {
|
||||
return query(page).evaluate((input: HTMLInputElement) => [
|
||||
input.selectionStart,
|
||||
input.selectionEnd,
|
||||
]);
|
||||
}
|
||||
/** The query box keeps the caret where the user left it, with nothing reselected. */
|
||||
async function expectQueryCaretKept(page: Page, text: string) {
|
||||
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
}
|
||||
async function expectQuerySelected(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(source(page)).toBeFocused();
|
||||
}
|
||||
async function expectActiveMatchUncovered(page: Page) {
|
||||
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
|
||||
await expect(match).toBeVisible();
|
||||
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const m = await match.boundingBox();
|
||||
const w = await widget.boundingBox();
|
||||
if (!m || !w) return "missing";
|
||||
const covered =
|
||||
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
|
||||
return covered ? "covered by Find" : "visible";
|
||||
})
|
||||
.toBe("visible");
|
||||
}
|
||||
|
||||
async function findBoxes(page: Page) {
|
||||
const pane = (await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
const widget = (await page
|
||||
.getByLabel("Find", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.boundingBox())!;
|
||||
return { pane, widget };
|
||||
}
|
||||
|
||||
async function expectFindInsidePane(page: Page) {
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
|
||||
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
|
||||
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
|
||||
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
|
||||
}
|
||||
|
||||
async function expectFindAtTopRight(page: Page) {
|
||||
await expectFindInsidePane(page);
|
||||
const { pane, widget } = await findBoxes(page);
|
||||
expect(widget.y).toBeLessThan(pane.y + 70);
|
||||
}
|
||||
|
||||
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-" });
|
||||
const file = path.join(workspace.repoPath, "source.txt");
|
||||
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "a.b");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("A.B");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("a.b");
|
||||
await expectFindAtTopRight(page);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await query(page).press("Shift+Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
|
||||
await closeFind(page);
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
|
||||
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("first literal");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.screenshot({ path: testInfo.outputPath("replace.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+z");
|
||||
await expect(source(page)).toContainText("first a.b");
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
|
||||
await findInSource(page, "a.b");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
|
||||
await page.getByRole("button", { name: "Replace all", exact: true }).click();
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
|
||||
const file = path.join(workspace.repoPath, "draft.txt");
|
||||
await writeFile(file, "saved on disk\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "draft.txt");
|
||||
await gate.drop();
|
||||
await source(page).fill("unsaved needle\nsecond needle\n");
|
||||
await findInSource(page, "needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
|
||||
gate.restore();
|
||||
});
|
||||
|
||||
test("searches read-only source beyond the viewport without replace controls", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "large.txt"),
|
||||
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "large.txt");
|
||||
await findInSource(page, "needle");
|
||||
await expect(source(page)).toHaveAttribute("contenteditable", "false");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expect(source(page)).toContainText("needle last");
|
||||
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("plain");
|
||||
await expect(status(page)).toHaveText("1 of 10000+");
|
||||
});
|
||||
|
||||
test("targets the focused source in split panes and refocuses an open query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const left = source(page).filter({ hasText: "left needle" });
|
||||
const right = source(page).filter({ hasText: "right needle" });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await left.click();
|
||||
await left.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await query(page).press("Escape");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
|
||||
});
|
||||
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("keeps Find controls inside a narrow source pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
async function selectFirstTwoLines(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Home");
|
||||
await source(page).press("Shift+ArrowDown");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
async function selectLastWord(page: Page) {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+End");
|
||||
await source(page).press("ArrowUp");
|
||||
await source(page).press("Home");
|
||||
await source(page).press("Shift+End");
|
||||
}
|
||||
|
||||
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
|
||||
const file = path.join(workspace.repoPath, "selection.txt");
|
||||
await writeFile(file, "alpha\nbeta\nalphabeta\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "selection.txt");
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await query(page).press("Enter");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await closeFind(page);
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+g");
|
||||
await expect(query(page)).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
|
||||
await closeFind(page);
|
||||
|
||||
await selectLastWord(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
|
||||
|
||||
await selectFirstTwoLines(page);
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toHaveValue("alphabeta");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(source(page)).toContainText("alpha");
|
||||
await expect(source(page)).toContainText("beta");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
|
||||
await closeFind(page);
|
||||
await source(page).press("ControlOrMeta+s");
|
||||
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
|
||||
});
|
||||
|
||||
for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
await findInSource(page, "needle");
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
const replacement = page.getByRole("textbox", { name: "Replace with" });
|
||||
await replacement.fill("replacement");
|
||||
|
||||
await test.step("repeat Find from the input and type a fresh query", async () => {
|
||||
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
|
||||
await startingInput.press("End");
|
||||
await startingInput.press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await page.keyboard.type("first");
|
||||
await expect(query(page)).toHaveValue("first");
|
||||
await expect(replacement).toHaveValue("replacement");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
|
||||
await closeFind(page);
|
||||
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
|
||||
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
|
||||
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "left.txt");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await openFileFromExplorer(page, "right.txt");
|
||||
const right = source(page).filter({ hasText: "needle one" });
|
||||
await expect(right).toBeVisible();
|
||||
await right.click();
|
||||
await right.press("ControlOrMeta+f");
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
|
||||
|
||||
await test.step("stays visible when Replace expands the widget", async () => {
|
||||
await page.getByRole("button", { name: "Toggle replace" }).click();
|
||||
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
|
||||
});
|
||||
|
||||
await test.step("navigation and query focus still work", async () => {
|
||||
await query(page).press("ControlOrMeta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await query(page).press("Escape");
|
||||
await expect(right).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("Go to line keeps its dialog with Find closed and open", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
|
||||
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "lines.txt");
|
||||
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
|
||||
|
||||
await test.step("with Find closed", async () => {
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
|
||||
await gotoInput.fill("12");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
|
||||
await expect(gotoInput).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("with Find open", async () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("line 3");
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+Alt+g");
|
||||
await expect(gotoInput).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
|
||||
await gotoInput.fill("30");
|
||||
await gotoInput.press("Enter");
|
||||
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
|
||||
await expect(query(page)).toHaveValue("line 3");
|
||||
await expect(status(page)).toHaveText("11 matches");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("macOS", () => {
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
|
||||
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
|
||||
await source(page).focus();
|
||||
await source(page).press("Control+f");
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
|
||||
await source(page).press("Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
|
||||
await test.step("Control+f inside the query box does not reclaim the field", async () => {
|
||||
await query(page).press("End");
|
||||
await query(page).press("Control+f");
|
||||
await expectQueryCaretKept(page, "needle");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
|
||||
await query(page).press("Meta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await closeFind(page);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
@@ -18,7 +19,7 @@ const scenarios = [
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
|
||||
planPrompt:
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
|
||||
planText: "Inspect permission steering",
|
||||
@@ -27,57 +28,151 @@ const scenarios = [
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("composer steer supersedes plan approval", () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
async function readConversationOrder(page: Page, prompt: string, reply: string) {
|
||||
const plan = page.getByTestId("timeline-plan-card");
|
||||
const question = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const answer = page.getByTestId("assistant-message").filter({ hasText: reply });
|
||||
const boxes = await Promise.all([
|
||||
plan.boundingBox(),
|
||||
question.boundingBox(),
|
||||
answer.boundingBox(),
|
||||
]);
|
||||
return boxes
|
||||
.map((box, index) => {
|
||||
if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered");
|
||||
return { role: ["plan", "question", "answer"][index], y: box.y };
|
||||
})
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map((item) => item.role);
|
||||
}
|
||||
|
||||
async function togglePlan(
|
||||
page: Page,
|
||||
testId: string,
|
||||
name: string,
|
||||
expanded: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(expanded));
|
||||
}
|
||||
|
||||
async function expectRejectedPlanCollapsed(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("timeline-plan-card")
|
||||
.getByRole("button", { name: "Rejected plan", exact: true }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
async function reopenConversation(page: Page, reply: string): Promise<void> {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible();
|
||||
}
|
||||
|
||||
type PlanScenario = (typeof scenarios)[number];
|
||||
interface PlanSteer {
|
||||
page: Page;
|
||||
scenario: PlanScenario;
|
||||
testInfo: TestInfo;
|
||||
}
|
||||
|
||||
async function withPlanSteer(context: PlanSteer, journey: () => Promise<void>): Promise<void> {
|
||||
const { page, scenario } = context;
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await journey();
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
await togglePlan(page, "permission-plan-card", "Plan", false);
|
||||
await expect(page.getByTestId("permission-request-deny")).toBeVisible();
|
||||
await togglePlan(page, "permission-plan-card", "Plan", true);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expectRejectedPlanCollapsed(page);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }),
|
||||
).toBeVisible();
|
||||
expect
|
||||
.soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply))
|
||||
.toEqual(["plan", "question", "answer"]);
|
||||
}
|
||||
|
||||
async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await togglePlan(page, "timeline-plan-card", "Rejected plan", true);
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await rejectedPlan.screenshot({
|
||||
path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`),
|
||||
});
|
||||
await togglePlan(page, "timeline-plan-card", "Rejected plan", false);
|
||||
}
|
||||
|
||||
async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise<void> {
|
||||
await reopenConversation(page, scenario.reply);
|
||||
await expectRejectedPlanCollapsed(page);
|
||||
expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([
|
||||
"plan",
|
||||
"question",
|
||||
"answer",
|
||||
]);
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const context = { page, scenario, testInfo };
|
||||
await withPlanSteer(context, async () => {
|
||||
await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context));
|
||||
await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context));
|
||||
await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context));
|
||||
await test.step("Keep conversation order after reload", () =>
|
||||
restoreConversationOrder(context));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForPermissionPrompt,
|
||||
} from "../support/helpers/permissions";
|
||||
import {
|
||||
assertComposerIdle,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
type AgentHandle,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
|
||||
const PLAN_PROMPT =
|
||||
"Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED.";
|
||||
|
||||
async function requestPlan(page: Page, prompt: string): Promise<void> {
|
||||
await submitMessage(page, prompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
await expect(page.getByTestId("permission-plan-card")).toContainText("README.md");
|
||||
}
|
||||
|
||||
function planWithOutcome(page: Page, label: string) {
|
||||
return page
|
||||
.getByTestId("timeline-plan-card")
|
||||
.filter({ has: page.getByRole("button", { name: label, exact: true }) });
|
||||
}
|
||||
|
||||
async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise<void> {
|
||||
const card = planWithOutcome(page, label);
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(expanded),
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise<void> {
|
||||
await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click();
|
||||
await expectPlanOutcome(page, label, expanded);
|
||||
}
|
||||
|
||||
async function expectClaudeReply(page: Page, marker: string): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
async function restartIsolatedDaemon(): Promise<void> {
|
||||
const port = Number(getE2EDaemonPort());
|
||||
expect([6767, 6768]).not.toContain(port);
|
||||
const client = await connectDaemonClient<{
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonStatus(): Promise<{ pid: number }>;
|
||||
restartServer(reason: string): Promise<unknown>;
|
||||
}>({ port, clientIdPrefix: "plan-history-restart" });
|
||||
try {
|
||||
const before = (await client.getDaemonStatus()).pid;
|
||||
await client.restartServer("Verify plan history replay in the isolated Playwright daemon");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return (await client.getDaemonStatus()).pid;
|
||||
} catch {
|
||||
return before;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.not.toBe(before);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRecordedPlans(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 });
|
||||
await expectPlanOutcome(page, "Rejected plan", false);
|
||||
await expectPlanOutcome(page, "Approved plan", true);
|
||||
}
|
||||
|
||||
interface PlanReview {
|
||||
page: Page;
|
||||
cwd: string;
|
||||
testInfo: TestInfo;
|
||||
}
|
||||
|
||||
async function withPlanReview(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
review: (context: PlanReview) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "haiku", modeId: "plan" },
|
||||
});
|
||||
await review({ page, cwd, testInfo });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise<void> {
|
||||
await requestPlan(page, PLAN_PROMPT);
|
||||
await denyPermission(page);
|
||||
await expectClaudeReply(page, "PLAN_REJECTED");
|
||||
await expectPlanOutcome(page, "Rejected plan", false);
|
||||
await toggleRecordedPlan(page, "Rejected plan", true);
|
||||
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
|
||||
await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") });
|
||||
await toggleRecordedPlan(page, "Rejected plan", false);
|
||||
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello");
|
||||
}
|
||||
|
||||
async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise<void> {
|
||||
await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`);
|
||||
await allowPermission(page);
|
||||
await expectClaudeReply(page, "PLAN_IMPLEMENTED");
|
||||
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello");
|
||||
await expectRecordedPlans(page);
|
||||
await toggleRecordedPlan(page, "Approved plan", false);
|
||||
await toggleRecordedPlan(page, "Approved plan", true);
|
||||
await page.screenshot({ path: testInfo.outputPath("approved-plan.png") });
|
||||
}
|
||||
|
||||
async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise<void> {
|
||||
await restartIsolatedDaemon();
|
||||
await page.reload();
|
||||
await expectRecordedPlans(page);
|
||||
await toggleRecordedPlan(page, "Rejected plan", true);
|
||||
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
|
||||
await toggleRecordedPlan(page, "Rejected plan", false);
|
||||
await page.screenshot({ path: testInfo.outputPath("restored-plans.png") });
|
||||
}
|
||||
|
||||
test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
await withPlanReview(page, testInfo, async (review) => {
|
||||
await test.step("Reject a plan without implementing it", () =>
|
||||
rejectPlanWithoutImplementing(review));
|
||||
await test.step("Approve and implement a fresh plan", () =>
|
||||
approveFreshPlanAndImplement(review));
|
||||
await test.step("Restore readable plans after daemon restart", () =>
|
||||
restoreReadablePlanHistory(review));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
|
||||
|
||||
test("the button author example shows each capability without a competing overflow button", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await withButtonAuthorExample(page, info, async (buttons) => {
|
||||
await buttons.refreshFromHeaderAndComposer();
|
||||
await buttons.useMenusAndToggleButtons();
|
||||
await buttons.inspectLiveWorkspaceDetails();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, async (buttons) => {
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installHostClientsScenario, readHost } from "../support/helpers/plugin-hosts";
|
||||
|
||||
test("plugin discovers offline hosts and borrows another host without installing there", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const hosts = await installHostClientsScenario(page);
|
||||
try {
|
||||
await hosts.expectOnline();
|
||||
await readHost(page, "selected", "selected:plugins=true");
|
||||
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
|
||||
await hosts.disposeSecondaryAndExpectObservationReleased();
|
||||
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
|
||||
await readHost(page, "missing", "Unknown Paseo host: missing");
|
||||
await hosts.reloadAndExpectObservationReleased();
|
||||
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
|
||||
await page.screenshot({ path: test.info().outputPath("two-hosts.png") });
|
||||
await hosts.restartSecondary();
|
||||
await hosts.expectOnline();
|
||||
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
|
||||
await hosts.useCompactLayout();
|
||||
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
|
||||
await page.screenshot({ path: test.info().outputPath("two-hosts-compact.png") });
|
||||
await hosts.disconnectSecondary();
|
||||
await readHost(page, "Secondary", `Paseo host is disconnected: ${hosts.serverId}`);
|
||||
await readHost(page, "selected", "selected:plugins=true");
|
||||
} finally {
|
||||
await hosts.close();
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,109 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openCompactSettings,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
import {
|
||||
startNpmRegistry,
|
||||
npmPluginPackages,
|
||||
} from "../../../../scripts/test-support/npm-registry.mjs";
|
||||
|
||||
interface PluginEnvironment {
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
const test = base.extend<
|
||||
{ pluginEnvironment: PluginEnvironment },
|
||||
{ npmRegistry: Awaited<ReturnType<typeof startNpmRegistry>> }
|
||||
>({
|
||||
pluginEnvironment: async ({ e2eWorker: _e2eWorker }, provide) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugins-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await provide({ client, directory });
|
||||
} finally {
|
||||
try {
|
||||
const current = await client.getDaemonConfig();
|
||||
const existingIds = new Set(Object.keys(previous.config.plugins ?? {}));
|
||||
for (const id of Object.keys(current.config.plugins ?? {})) {
|
||||
if (!existingIds.has(id)) await client.removePlugin(id);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previous.config.pluginsEnabled ?? false,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
npmRegistry: [
|
||||
async ({ browserName: _browserName }, provide) => {
|
||||
const registry = await startNpmRegistry(npmPluginPackages());
|
||||
try {
|
||||
await provide(registry);
|
||||
} finally {
|
||||
await registry.close();
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
e2eDaemonEnvironment: [
|
||||
async ({ npmRegistry }, provide) => {
|
||||
await provide(npmRegistry.env);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
async function advertisePluginCapabilities(
|
||||
page: Page,
|
||||
features: Record<string, boolean | undefined>,
|
||||
): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.message?.type === "status" &&
|
||||
envelope.message.payload?.status === "server_info"
|
||||
) {
|
||||
Object.assign(envelope.message.payload.features, features);
|
||||
}
|
||||
browser.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observePluginCatalog(page: Page) {
|
||||
let responses = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
@@ -72,11 +164,28 @@ async function openPluginSettings(page: Page): Promise<void> {
|
||||
await expectSettingsHeader(page, "Plugins");
|
||||
}
|
||||
|
||||
async function openCompactPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
|
||||
await expect(page.getByRole("menuitem").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
|
||||
await openPluginActions(page, pluginId);
|
||||
await page.getByRole("menuitem", { name: action, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
|
||||
await expect(body).toHaveCount(1);
|
||||
@@ -115,99 +224,434 @@ async function expectContributionRemoved(page: Page, title: string): Promise<voi
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
async function capturePluginInstallForm(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: "wide" | "wide-menu" | "compact-error" | "compact-success" | "compact-menu",
|
||||
): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`plugin-source-install-${name}.png`),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
async function installPlugin(page: Page, source: string): Promise<void> {
|
||||
await page.getByLabel("Plugin source").fill(source);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
}
|
||||
|
||||
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
|
||||
const requestedPage = page
|
||||
.context()
|
||||
.waitForEvent(
|
||||
"request",
|
||||
(request) => request.isNavigationRequest() && request.url().startsWith("https://paseo.sh/"),
|
||||
);
|
||||
const docsPagePromise = page.context().waitForEvent("page");
|
||||
await page.getByRole("link", { name: "Docs", exact: true }).click();
|
||||
const request = await requestedPage;
|
||||
const docsPage = await docsPagePromise;
|
||||
try {
|
||||
expect(new URL(request.url()).pathname).toBe("/docs/plugins/reference");
|
||||
// The deployed site can redirect while the matching website change is still in this PR.
|
||||
await docsPage.waitForURL(
|
||||
(url) => url.origin === "https://paseo.sh" && url.hash === "#plugin-sources",
|
||||
{ waitUntil: "commit" },
|
||||
);
|
||||
} finally {
|
||||
await docsPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function createGitPluginRepository(root: string): Promise<string> {
|
||||
const repository = path.join(root, "repository");
|
||||
await mkdir(repository);
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "git-e2e-plugin",
|
||||
description: "Adds review tools from a Git repository",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Git plugin"));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function createDirectoryPlugin(
|
||||
root: string,
|
||||
id: string,
|
||||
description: string | undefined,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(root, "plugin-row-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
|
||||
JSON.stringify({ id, description, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(plugin) {
|
||||
function Surface() { return <Text>${title}</Text>; }
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
await client.installPluginSource({ source: directory });
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
|
||||
await client.reloadPlugin("failed-preview-plugin");
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
const directory = await installLocalPluginWithStatusExamples(page, pluginEnvironment, testInfo);
|
||||
await reloadAndRecoverLocalPlugin(page, pluginEnvironment.client, directory);
|
||||
await toggleRemoveAndReinstallLocalPlugin(page, directory);
|
||||
});
|
||||
|
||||
test("installs a Git source after a failed source remains editable", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
const source = await prepareCompactGitInstall(page, pluginEnvironment);
|
||||
await retryGitInstallAndInspectRows(page, source, testInfo);
|
||||
});
|
||||
|
||||
for (const sourceSupport of [undefined, false]) {
|
||||
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}) => {
|
||||
const directory = await prepareLegacyPluginHost(page, pluginEnvironment, sourceSupport);
|
||||
await verifyLegacyPluginManagement(page, directory);
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1280, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
await prepareNpmPluginHost(page, pluginEnvironment, viewport);
|
||||
await retryNpmInstallAndInspectRow(page, viewport.width, testInfo);
|
||||
await reloadAndRemoveNpmPlugin(page);
|
||||
});
|
||||
}
|
||||
|
||||
async function openNpmPluginSettings(page: Page, width: number) {
|
||||
if (width < 600) await openCompactPluginSettings(page);
|
||||
else await openPluginSettings(page);
|
||||
}
|
||||
|
||||
async function expectSourceHierarchy(page: Page, description: string, source: string) {
|
||||
const descriptionText = page.getByText(description, { exact: false });
|
||||
const sourceText = page.getByText(source, { exact: false });
|
||||
const descriptionSize = await descriptionText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
);
|
||||
const sourceSize = await sourceText.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
);
|
||||
expect(sourceSize).toBeLessThan(descriptionSize);
|
||||
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
|
||||
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
|
||||
}
|
||||
|
||||
async function installLocalPluginWithStatusExamples(
|
||||
page: Page,
|
||||
pluginEnvironment: PluginEnvironment,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const directory = await mkdtemp(path.join(pluginEnvironment.directory, "local-"));
|
||||
const disabledDirectory = await createDirectoryPlugin(
|
||||
pluginEnvironment.directory,
|
||||
"disabled-preview-plugin-with-a-long-name",
|
||||
"Keeps optional previews off until this plugin is enabled",
|
||||
"Preview plugin",
|
||||
);
|
||||
const failedDirectory = await createDirectoryPlugin(
|
||||
pluginEnvironment.directory,
|
||||
"failed-preview-plugin",
|
||||
"Demonstrates a plugin that needs attention",
|
||||
"Failed preview plugin",
|
||||
);
|
||||
const { client } = pluginEnvironment;
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "e2e-plugin",
|
||||
description: "Exercises the complete local plugin lifecycle",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByLabel("Plugin installation ID").focus();
|
||||
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await expectPluginSourceDocsOpen(page);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
|
||||
await installFailedPlugin(page, client, failedDirectory);
|
||||
await page.getByLabel("Plugin source").fill(directory);
|
||||
await page.getByRole("button", { name: "Install plugin" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Keeps optional previews off until this plugin is enabled"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
|
||||
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
|
||||
await capturePluginInstallForm(page, testInfo, "wide");
|
||||
await openPluginActions(page, "e2e-plugin");
|
||||
await capturePluginInstallForm(page, testInfo, "wide-menu");
|
||||
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
return directory;
|
||||
}
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
async function reloadAndRecoverLocalPlugin(
|
||||
page: Page,
|
||||
client: PluginEnvironment["client"],
|
||||
directory: string,
|
||||
) {
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
}
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
} finally {
|
||||
await client.removePlugin("e2e-plugin").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "e2e-plugin", "Remove");
|
||||
await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
|
||||
|
||||
await installPlugin(page, directory);
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
}
|
||||
|
||||
async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnvironment) {
|
||||
const root = pluginEnvironment.directory;
|
||||
const repository = await createGitPluginRepository(root);
|
||||
const longDirectory = await createDirectoryPlugin(
|
||||
pluginEnvironment.directory,
|
||||
"compact-plugin-with-a-realistically-long-name",
|
||||
"Shows how a realistically long plugin description wraps on a compact screen",
|
||||
"Compact long plugin",
|
||||
);
|
||||
const disabledDirectory = await createDirectoryPlugin(
|
||||
pluginEnvironment.directory,
|
||||
"compact-disabled-plugin",
|
||||
"Remains installed and ready to enable later",
|
||||
"Compact disabled plugin",
|
||||
);
|
||||
const missingSource = path.join(root, "missing-plugin");
|
||||
const { client } = pluginEnvironment;
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await client.installPluginSource({ source: longDirectory });
|
||||
await client.installPluginSource({ source: disabledDirectory });
|
||||
await client.disablePlugin("compact-disabled-plugin");
|
||||
await gotoAppShell(page);
|
||||
await openCompactPluginSettings(page);
|
||||
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
|
||||
return { repository, missingSource };
|
||||
}
|
||||
|
||||
async function retryGitInstallAndInspectRows(
|
||||
page: Page,
|
||||
source: { repository: string; missingSource: string },
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const { repository, missingSource } = source;
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Plugin source is neither an existing directory nor a Git URL",
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
|
||||
await installPlugin(page, pathToFileURL(repository).href);
|
||||
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("");
|
||||
await capturePluginInstallForm(page, testInfo, "compact-success");
|
||||
await openPluginActions(page, "git-e2e-plugin");
|
||||
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
|
||||
await expect(removeAction).toBeInViewport({ ratio: 1 });
|
||||
await waitForSettledPosition(removeAction);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function prepareLegacyPluginHost(
|
||||
page: Page,
|
||||
pluginEnvironment: PluginEnvironment,
|
||||
sourceSupport: boolean | undefined,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
pluginEnvironment.directory,
|
||||
"legacy-source-plugin",
|
||||
undefined,
|
||||
"Legacy plugin",
|
||||
);
|
||||
const { client } = pluginEnvironment;
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
await advertisePluginCapabilities(page, {
|
||||
pluginSourceInstallation: sourceSupport,
|
||||
pluginGitManagement: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function verifyLegacyPluginManagement(page: Page, directory: string) {
|
||||
await expect(
|
||||
page.getByText("Update this host to install plugins", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(directory, { exact: true })).toBeVisible();
|
||||
await selectPluginAction(page, "legacy-source-plugin", "Reload");
|
||||
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function prepareNpmPluginHost(
|
||||
page: Page,
|
||||
pluginEnvironment: PluginEnvironment,
|
||||
viewport: { width: number; height: number },
|
||||
) {
|
||||
const { client } = pluginEnvironment;
|
||||
await page.setViewportSize(viewport);
|
||||
await advertisePluginCapabilities(page, { pluginGitManagement: false });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await gotoAppShell(page);
|
||||
await openNpmPluginSettings(page, viewport.width);
|
||||
}
|
||||
|
||||
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
|
||||
await installPlugin(page, "npm:missing-plugin");
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
|
||||
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
|
||||
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toBeVisible();
|
||||
await expectSourceHierarchy(
|
||||
page,
|
||||
"Installed from the npm fixture registry",
|
||||
"npm:@paseo-fixture/review · 2.0.0",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Installed from the npm fixture registry", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`npm-plugin-${width}.png`),
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await selectPluginAction(page, "npm-review", "Reload");
|
||||
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await selectPluginAction(page, "npm-review", "Remove");
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ async function openExample(page: Page, example: string) {
|
||||
|
||||
async function closeExample(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
// The launcher is visible behind a closing sheet; wait for dismissal before reopening.
|
||||
await expect(page.getByText(/^Modal example:/)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
test,
|
||||
verifyProviderSettings,
|
||||
verifyNewWorkspaceModelIcon,
|
||||
verifyCompactModelIcon,
|
||||
verifyExistingAgentModelIcon,
|
||||
} from "../support/helpers/plugin-provider-icons";
|
||||
|
||||
test("plugin provider icons follow the model through settings and wide and compact composers", async ({
|
||||
providerIcons,
|
||||
}) => {
|
||||
await verifyProviderSettings(providerIcons);
|
||||
await verifyNewWorkspaceModelIcon(providerIcons);
|
||||
await verifyCompactModelIcon(providerIcons);
|
||||
await verifyExistingAgentModelIcon(providerIcons);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user