mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8860f2eb14 | ||
|
|
fb349d6bb7 |
@@ -52,7 +52,7 @@ app:
|
||||
sdk:
|
||||
- "packages/plugin/**"
|
||||
- "plugin-examples/**"
|
||||
- "public-docs/plugins/**"
|
||||
- "public-docs/plugins/v0.8/**"
|
||||
- "packages/client/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
|
||||
+29
-45
@@ -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 scripts/upload-release-assets.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
@@ -120,12 +120,6 @@ 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
|
||||
@@ -184,7 +178,7 @@ jobs:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -207,35 +201,12 @@ jobs:
|
||||
|
||||
- name: Install virtual display
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- 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
|
||||
@@ -255,6 +226,23 @@ 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
|
||||
@@ -308,7 +296,7 @@ jobs:
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:relay && npm run build:client
|
||||
run: npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
@@ -316,6 +304,9 @@ 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
|
||||
|
||||
@@ -364,9 +355,6 @@ 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
|
||||
@@ -452,18 +440,14 @@ 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
|
||||
|
||||
# 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
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
|
||||
cli-tests-2:
|
||||
name: cli-tests (shard 2/3)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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,10 +102,8 @@ 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
|
||||
@@ -163,10 +161,6 @@ 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 }})
|
||||
@@ -191,8 +185,16 @@ 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")"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -259,7 +261,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
@@ -276,7 +278,7 @@ jobs:
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: always()
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-linux-x64
|
||||
@@ -292,8 +294,16 @@ 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")"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -390,8 +400,16 @@ 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")"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
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:
|
||||
@@ -30,7 +51,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
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
|
||||
@@ -12,6 +31,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -89,6 +110,8 @@ 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:
|
||||
|
||||
+58
-146
@@ -1,179 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
### Added
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
- 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))
|
||||
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 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 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))
|
||||
|
||||
### 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 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 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 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))
|
||||
- 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))
|
||||
|
||||
### 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 ([#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))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
|
||||
### Improved
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
### 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 before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- 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 `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 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 cached conversations and the workspace list not painting when live updates arrived during cache reads ([#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 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 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 Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- 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 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 Import session ignoring Claude Code `/rename` titles ([#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 importing a session into a directory with an active workspace creating a duplicate workspace ([#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 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 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 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))
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
|
||||
@@ -106,15 +106,10 @@ 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.
|
||||
|
||||
## Release branches
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
@@ -125,7 +120,6 @@ and updating `next`, integrating it after a release, and releasing a hotfix from
|
||||
- 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.
|
||||
|
||||
@@ -50,9 +50,10 @@ 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 npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
|
||||
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
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
|
||||
machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -51,7 +51,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
@@ -81,7 +81,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... test ...
|
||||
|
||||
|
||||
+10
-29
@@ -109,28 +109,16 @@ 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 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.
|
||||
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.
|
||||
|
||||
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,
|
||||
@@ -164,13 +152,6 @@ A finished workspace can be marked unread after it has been reviewed. The daemon
|
||||
`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,8 +12,7 @@ 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
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → 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`.
|
||||
@@ -29,23 +28,6 @@ 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,17 +120,6 @@ 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:
|
||||
|
||||
+6
-60
@@ -54,15 +54,6 @@ 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
|
||||
@@ -154,7 +145,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/run/stop/restart/status/reload/config/pair/set-password`
|
||||
- `paseo daemon start/stop/restart/status/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`
|
||||
@@ -248,22 +239,11 @@ Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
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.
|
||||
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(...)`.
|
||||
|
||||
**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, 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -317,7 +297,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)`.
|
||||
- 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.
|
||||
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
Example: adding a new enum value
|
||||
|
||||
@@ -328,36 +308,6 @@ 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`:
|
||||
@@ -468,16 +418,12 @@ $PASEO_HOME/
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.pid # Supervisor identity and published bound endpoint
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
└── 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 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.
|
||||
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`).
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -12,7 +12,8 @@ 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;
|
||||
- parked guests remain capturable with Chromium background throttling enabled;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination;
|
||||
- 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
|
||||
@@ -72,17 +73,11 @@ The harness writes PNG evidence and `results.json` to:
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
|
||||
A passing run prints `PASS` lines for the production P1 attach-off 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
|
||||
@@ -101,11 +96,8 @@ 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 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.
|
||||
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.
|
||||
|
||||
@@ -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`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+8
-10
@@ -64,8 +64,8 @@ $PASEO_HOME/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
├── plugins/
|
||||
│ ├── sources.json # Managed kind and Git acquisition remote
|
||||
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
|
||||
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -254,13 +254,11 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Profile lists
|
||||
|
||||
@@ -490,7 +488,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` | 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. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `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 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.
|
||||
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`).
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
|
||||
+4
-5
@@ -444,17 +444,16 @@ 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 in persistent configuration:
|
||||
Enable it for a running daemon with:
|
||||
|
||||
```bash
|
||||
paseo daemon config set features.webUi.enabled true
|
||||
paseo daemon start
|
||||
paseo daemon start --web-ui
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon run
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
@@ -565,7 +564,7 @@ npm run cli -- --host ssh://user@host ls -a
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
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).
|
||||
`--host` overrides the environment variable.
|
||||
|
||||
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,8 +22,6 @@ 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,10 +30,7 @@ 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. 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.
|
||||
Explorer sidebar setting. 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.
|
||||
|
||||
@@ -48,7 +45,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. 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.
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
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
|
||||
@@ -60,11 +57,8 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Placement intent still controls existing tabs:
|
||||
|
||||
|
||||
@@ -47,15 +47,6 @@ 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
|
||||
|
||||
+5
-59
@@ -58,10 +58,7 @@ 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. 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
|
||||
hit-testing starts fresh in that window. 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).
|
||||
@@ -137,38 +134,13 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`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
|
||||
`KeyboardTranslateView` owns visual keyboard motion. 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 the keyboard module's private platform-specific translate files.
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
|
||||
`ComposerDock` always keeps the surface at full height and wraps it in
|
||||
`KeyboardDock` always keeps the chat 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
|
||||
@@ -182,33 +154,7 @@ 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.
|
||||
|
||||
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
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
|
||||
@@ -49,10 +49,6 @@ 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,10 +179,6 @@ 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
|
||||
@@ -191,10 +187,6 @@ 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,8 +116,6 @@ 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,33 +47,6 @@ 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
|
||||
|
||||
+19
-69
@@ -4,7 +4,7 @@ Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Com
|
||||
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 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 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.
|
||||
|
||||
## 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/reference.md#requirements), including legacy
|
||||
[requirements contract](../public-docs/plugins/v0.8/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,36 +103,10 @@ paseo plugin update --all
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
`--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.
|
||||
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.
|
||||
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
|
||||
@@ -188,10 +162,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/reference.md#host-ui).
|
||||
[host UI contract](../public-docs/plugins/v0.8/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/reference.md`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
|
||||
### SDK import boundaries
|
||||
|
||||
@@ -232,7 +206,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/reference.md#works-on-mobile) for the complete
|
||||
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
@@ -279,11 +253,6 @@ 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
|
||||
@@ -292,7 +261,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. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
query cache. Plugin code cannot address 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
|
||||
@@ -309,16 +278,15 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
`server.before()`. The [public reference](../public-docs/plugins/v0.8/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.
|
||||
|
||||
@@ -366,7 +334,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/providers.md)
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/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`.
|
||||
@@ -379,7 +347,7 @@ SVG or URL.
|
||||
## Contribute buttons
|
||||
|
||||
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)
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/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.
|
||||
|
||||
@@ -387,26 +355,12 @@ Native sheets teleport their children. Button surfaces rebuild the installation'
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
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, 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
|
||||
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
|
||||
streaming updates preserve mounted component identity.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
@@ -503,7 +457,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/reference.md#settings-screens)
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/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.
|
||||
@@ -511,10 +465,6 @@ 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
|
||||
@@ -538,7 +488,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/migration.md).
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/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
|
||||
|
||||
@@ -46,32 +46,10 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource
|
||||
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,
|
||||
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:
|
||||
|
||||
@@ -49,13 +49,6 @@ 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.
|
||||
|
||||
+1
-32
@@ -47,36 +47,6 @@ 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:
|
||||
@@ -94,7 +64,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 resolved release source to everyone immediately (default `origin/main`).
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
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
|
||||
@@ -192,7 +162,6 @@ 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.
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ 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.
|
||||
|
||||
|
||||
+4
-8
@@ -107,18 +107,14 @@ 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 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 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 `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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
To exercise the smoke locally on Linux:
|
||||
|
||||
@@ -132,7 +128,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 after merge. PRs need the fast dependency-closure test below because packaging runs on main.
|
||||
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`.
|
||||
|
||||
`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`.
|
||||
|
||||
@@ -204,7 +200,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 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.
|
||||
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.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
+14
-64
@@ -5,10 +5,8 @@ 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 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 daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||
authority and repopulates those rows when an agent resumes.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -69,34 +67,15 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -109,25 +88,6 @@ 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
|
||||
@@ -135,11 +95,6 @@ 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.
|
||||
|
||||
@@ -173,19 +128,14 @@ replica cache.
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6du3a0DzuvF+m2cPTOXDhdZZScQPkqicikdDxWIKZ5o=
|
||||
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
|
||||
|
||||
Generated
+27
-55
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36268,7 +36268,6 @@
|
||||
"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",
|
||||
@@ -37262,12 +37261,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/server": "0.9.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37513,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37527,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37539,7 +37538,6 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
@@ -37772,7 +37770,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38666,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38695,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38712,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38735,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38762,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,16 +38980,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/highlight": "0.9.0",
|
||||
"@getpaseo/plugin": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@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",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39004,7 +39002,6 @@
|
||||
"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",
|
||||
@@ -39015,7 +39012,6 @@
|
||||
"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",
|
||||
@@ -39028,7 +39024,6 @@
|
||||
"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",
|
||||
@@ -39473,17 +39468,6 @@
|
||||
"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",
|
||||
@@ -39722,18 +39706,6 @@
|
||||
"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",
|
||||
@@ -39895,7 +39867,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.8.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -142,7 +141,6 @@ export default {
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
|
||||
@@ -36,18 +36,14 @@ 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 {
|
||||
recordPanelToasts,
|
||||
switchWorkspaceViaSidebar,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
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,
|
||||
@@ -432,18 +428,6 @@ 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 },
|
||||
@@ -459,76 +443,50 @@ 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);
|
||||
// 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",
|
||||
);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamItemSuppressed("user_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
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);
|
||||
|
||||
// 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);
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
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 subscriptions.waitForSubscribedAgents([
|
||||
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
|
||||
]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
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);
|
||||
// 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);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamItemSuppressed("user_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
@@ -910,7 +868,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
|
||||
test("keeps one canonical prompt when the provider echoes before accepting", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -931,20 +889,6 @@ 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();
|
||||
}
|
||||
@@ -964,12 +908,16 @@ 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(submittedPrompt).toHaveCount(1);
|
||||
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
} finally {
|
||||
@@ -1166,11 +1114,8 @@ 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 { captureWorkspaceAgentRequest } from "../support/helpers/creation";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
@@ -22,6 +22,53 @@ 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 }) => {
|
||||
@@ -96,7 +143,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -159,7 +206,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -88,12 +88,18 @@ 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
-55
@@ -1,12 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { metroTest as test } 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";
|
||||
@@ -24,19 +17,6 @@ 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,
|
||||
@@ -99,40 +79,6 @@ 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();
|
||||
@@ -1,10 +1,6 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
withSpokenTimeline,
|
||||
captureSpokenTimeline,
|
||||
reloadSpokenTimeline,
|
||||
expectSpokenTimelinePrompt,
|
||||
sendSpokenTimelinePrompt,
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectStableHistoryStartGutter,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
@@ -16,6 +12,7 @@ import {
|
||||
holdOlderHistoryPages,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
@@ -29,28 +26,42 @@ 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("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 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("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);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the history-start gutter and visible position stable through the final page", async ({
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
@@ -62,10 +73,8 @@ 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 {
|
||||
@@ -87,9 +96,7 @@ 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);
|
||||
@@ -130,6 +137,23 @@ 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,
|
||||
}) => {
|
||||
@@ -157,8 +181,7 @@ test.describe("Agent timeline pagination", () => {
|
||||
|
||||
test("finishes loading an older page while live output continues", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// 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" });
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
@@ -170,11 +193,8 @@ 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(
|
||||
@@ -185,7 +205,28 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
|
||||
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.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
@@ -194,12 +235,12 @@ test.describe("Agent timeline pagination", () => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await history.expectRequestedPages(1);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
const loading = await rememberOlderHistoryLoadingOperation(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelineAtHistoryStart(page);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
await expectSameOlderHistoryLoadingOperation(page, loading);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ 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, Locator } from "@playwright/test";
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -97,20 +97,14 @@ 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 = assistantMessageBlocks(page);
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -130,7 +124,9 @@ async function selectAssistantListItemFromMarker(
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
@@ -174,7 +170,9 @@ async function doubleClickAssistantMarkdownText(
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
@@ -186,20 +184,12 @@ async function selectAssistantTextRange(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
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 };
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
@@ -251,20 +241,12 @@ async function selectAssistantAcrossCodeLines(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
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 };
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
@@ -344,7 +326,9 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
for (const [tag, text] of [
|
||||
["strong", "strong prose"],
|
||||
["em", "emphasized prose"],
|
||||
|
||||
@@ -876,32 +876,6 @@ 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);
|
||||
@@ -980,21 +954,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("changes diff waits for configured fonts, then applies code typography changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
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);
|
||||
});
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const before = await readDiffTypographyGeometry(page);
|
||||
|
||||
await changeCodeTypographyFromSettings(page, {
|
||||
@@ -1013,31 +977,63 @@ test("changes diff waits for configured fonts, then applies code typography chan
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
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 ({
|
||||
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 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
@@ -1147,36 +1143,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("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
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);
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
@@ -1190,6 +1186,19 @@ 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,
|
||||
@@ -1384,17 +1393,14 @@ 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);
|
||||
@@ -1404,7 +1410,6 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
Object.assign(window, {
|
||||
__releasePaseoDiffFontLoads() {
|
||||
released = true;
|
||||
for (const release of pending.splice(0)) release();
|
||||
},
|
||||
});
|
||||
@@ -1654,72 +1659,12 @@ 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);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(
|
||||
@@ -1875,41 +1820,6 @@ 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 { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -12,7 +12,6 @@ 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);
|
||||
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
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,8 +20,6 @@ import {
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
@@ -43,31 +41,6 @@ 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 { expect, test } from "../support/fixtures";
|
||||
import { test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
@@ -79,27 +79,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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,7 +3,6 @@ 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";
|
||||
@@ -110,13 +109,6 @@ 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,7 +38,6 @@ 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==",
|
||||
@@ -53,29 +52,17 @@ const TEST_JSON = {
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
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-" });
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
upload.hold();
|
||||
await attachFileFromMenu(page, TEST_JSON);
|
||||
await upload.waitForUpload();
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
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);
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("compact Plus menu aligns attachment rows with its sheet title", async ({
|
||||
@@ -114,27 +101,59 @@ test.describe("Composer attachments", () => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
|
||||
test("GitHub issue 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: "attachments",
|
||||
issues: [{ title: "fix: attachment issue" }],
|
||||
prs: [{ title: "feat: attachment pull request", state: "open" }],
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
// 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]);
|
||||
|
||||
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,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
@@ -149,7 +168,7 @@ test.describe("Composer attachments", () => {
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test("attaches, previews, and removes an image before dropping a file", async ({
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -159,25 +178,38 @@ 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);
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
@@ -206,6 +238,23 @@ 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,7 +9,6 @@ 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 = [
|
||||
{
|
||||
@@ -672,7 +671,7 @@ test.describe("Composer autocomplete", () => {
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
@@ -70,7 +70,6 @@ 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 { writeFile } from "node:fs/promises";
|
||||
import { rm, 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,13 +30,10 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Composer diff stat",
|
||||
repo: {
|
||||
withRemote: true,
|
||||
// Exclude the local remote before the daemon starts observing this checkout.
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
||||
},
|
||||
repo: { withRemote: true },
|
||||
});
|
||||
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,19 +65,6 @@ 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`);
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/* 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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,10 +1,6 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import {
|
||||
clickNewChat,
|
||||
gotoWorkspace,
|
||||
pressDirectNewTabShortcut,
|
||||
} from "../support/helpers/launcher";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -15,7 +11,6 @@ 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 { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -43,7 +43,6 @@ 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 { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
@@ -93,221 +93,132 @@ 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 = collectConsoleErrors(page);
|
||||
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 workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
|
||||
let agentId = "";
|
||||
|
||||
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",
|
||||
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 });
|
||||
});
|
||||
|
||||
// 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 test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
});
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
|
||||
await testInfo.attach("console-errors", {
|
||||
body: JSON.stringify(consoleErrors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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,6 +111,21 @@ 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,66 +82,121 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
|
||||
},
|
||||
});
|
||||
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe("Host appearance", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
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 () => {
|
||||
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
};
|
||||
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
|
||||
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 renameHostFromSettings(page, hostName);
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectHostBadgeName(page, { ...badge, hostName });
|
||||
|
||||
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()]);
|
||||
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()]);
|
||||
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
client: label.clientWidth,
|
||||
content: label.scrollWidth,
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
scrollWidth: label.scrollWidth,
|
||||
}));
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.client);
|
||||
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
|
||||
});
|
||||
|
||||
await test.step("preview a color and apply it to the sidebar badge", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
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 chooseHostColor(page, "Teal");
|
||||
await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" });
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" });
|
||||
|
||||
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
|
||||
});
|
||||
|
||||
await test.step("keep the icon-only preference through reload", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
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 chooseHostBadgeDisplay(page, "Icon only");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
await reloadPreservingHostRegistry(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
|
||||
await expectHostBadgeIconOnly(page, badge);
|
||||
});
|
||||
|
||||
await test.step("hide this badge without hiding the other host", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
test("hiding one host leaves the other host's badge visible", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await chooseHostBadgeDisplay(page, "Hidden");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectNoHostBadge(page, { ...badge, hostName });
|
||||
|
||||
await expectNoHostBadge(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
});
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
expectTabTitleFits,
|
||||
terminalSurfaceLocator,
|
||||
} from "../support/helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
||||
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
setupDeterministicPrompt,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
@@ -39,7 +43,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-empty-prompt",
|
||||
name: "Empty Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
|
||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||
@@ -122,40 +126,47 @@ test.describe("Tab creation", () => {
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
test("opens the menu, then creates independent New tabs without creating agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await pressNewTabShortcut(page);
|
||||
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);
|
||||
|
||||
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 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();
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||
@@ -256,6 +267,110 @@ 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,7 +1,4 @@
|
||||
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 { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
expectModelSearchEmptyState,
|
||||
@@ -18,7 +15,7 @@ import {
|
||||
expectSearchResultsVirtualized,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
@@ -161,111 +158,3 @@ 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,10 +1,9 @@
|
||||
import { expect, type Page } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
import { expect, test, type Page } from "../support/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 { captureWorkspaceAgentRequest } from "../support/helpers/creation";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -18,6 +17,40 @@ 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",
|
||||
@@ -109,15 +142,44 @@ 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 captureWorkspaceAgentRequest(page, { block: false });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
try {
|
||||
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
await gotoAppShell(page);
|
||||
@@ -138,12 +200,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
).toBeVisible();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
|
||||
|
||||
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",
|
||||
@@ -151,8 +209,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
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";
|
||||
@@ -19,7 +18,7 @@ const scenarios = [
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
|
||||
providerConfig: { 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",
|
||||
@@ -28,151 +27,57 @@ const scenarios = [
|
||||
},
|
||||
];
|
||||
|
||||
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({
|
||||
test.describe("composer steer supersedes plan approval", () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
}, 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 });
|
||||
}
|
||||
});
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,9 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, async (buttons) => {
|
||||
await buttons.verifyObservationLifetime();
|
||||
await withButtonShowcase(page, testInfo, async (buttons) => {
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,109 +1,17 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
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 { expect, test, 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) => {
|
||||
@@ -164,28 +72,11 @@ 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 selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
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);
|
||||
@@ -224,434 +115,99 @@ async function expectContributionRemoved(page: Page, title: string): Promise<voi
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
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, 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;
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "e2e-plugin",
|
||||
description: "Exercises the complete local plugin lifecycle",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
return directory;
|
||||
}
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
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("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("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("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("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("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("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"), "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"), "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 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"), 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: "Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
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);
|
||||
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");
|
||||
|
||||
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 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);
|
||||
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);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
requestPluginTimeline,
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
test(`assistant plugin receives the whole streaming message at width ${width}`, async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => {
|
||||
await withTimelinePlugin(page, info, "tools", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectBothConsecutiveTools(page);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectBothConsecutiveTools(page);
|
||||
});
|
||||
});
|
||||
@@ -27,18 +27,11 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginClientSource(input: {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
remoteServerId: string;
|
||||
remoteWorkspaceId: string;
|
||||
remoteAgentId: string;
|
||||
}): string {
|
||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
|
||||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
@@ -57,14 +50,9 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
|
||||
function DirectCollisionSurface({ navigation }) {
|
||||
return <View>
|
||||
<Text>Direct collision surface</Text>
|
||||
<Text>{navigation.openBrowser ? "Browser available" : "Browser unavailable"}</Text>
|
||||
<ExternalLink href="https://example.com/">Plugin documentation</ExternalLink>
|
||||
<Pressable accessibilityRole="button" onPress={() => openExternalUrl("https://example.com/")}><Text>Open external plugin URL</Text></Pressable>
|
||||
{navigation ? <>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ serverId: ${JSON.stringify(input.remoteServerId)}, workspaceId: ${JSON.stringify(input.remoteWorkspaceId)} })}><Text>Open remote workspace from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ serverId: ${JSON.stringify(input.remoteServerId)}, agentId: ${JSON.stringify(input.remoteAgentId)} })}><Text>Open remote agent from plugin</Text></Pressable>
|
||||
</> : null}
|
||||
</View>;
|
||||
}
|
||||
@@ -79,7 +67,7 @@ function contributeClient(client) {
|
||||
pills.get(agentId)?.();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const apply = (update) => {
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind === "remove") {
|
||||
remove(update.agentId);
|
||||
return;
|
||||
@@ -106,20 +94,9 @@ function contributeClient(client) {
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, () => pill.remove());
|
||||
};
|
||||
const lifetime = new AbortController();
|
||||
void client.paseo.agents.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
snapshot({ entries }) {
|
||||
for (const removePill of pills.values()) removePill();
|
||||
pills.clear();
|
||||
for (const { agent } of entries) apply({ kind: "upsert", agent });
|
||||
},
|
||||
update(message) { if (message.type === "agent_update") apply(message.payload); },
|
||||
});
|
||||
}).catch((error) => { if (!lifetime.signal.aborted) console.error(error); });
|
||||
});
|
||||
return () => {
|
||||
lifetime.abort();
|
||||
unsubscribe();
|
||||
for (const removePill of pills.values()) removePill();
|
||||
pills.clear();
|
||||
};
|
||||
@@ -160,13 +137,7 @@ export default function contribute(server) {
|
||||
|
||||
async function writePluginSources(
|
||||
directory: string,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
remoteServerId: string;
|
||||
remoteWorkspaceId: string;
|
||||
remoteAgentId: string;
|
||||
},
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await mkdir(path.join(directory, "shared"), { recursive: true });
|
||||
await Promise.all([
|
||||
@@ -200,52 +171,6 @@ async function runCommand(page: Page, title: string): Promise<void> {
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function openPluginExternalTab(page: Page, role: "link" | "button", name: string) {
|
||||
const opened = page.context().waitForEvent("page");
|
||||
await page.getByRole(role, { name, exact: true }).click();
|
||||
const tab = await opened;
|
||||
await tab.waitForURL("https://example.com/");
|
||||
await tab.close();
|
||||
}
|
||||
|
||||
interface RemoteNavigationTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function remoteWorkspaceRoute(target: RemoteNavigationTarget): RegExp {
|
||||
return new RegExp(
|
||||
`/h/${encodeURIComponent(target.serverId)}/workspace/${encodeURIComponent(target.workspaceId)}(?:\\?.*)?$`,
|
||||
);
|
||||
}
|
||||
|
||||
async function openRemoteWorkspaceFromPlugin(
|
||||
page: Page,
|
||||
target: RemoteNavigationTarget,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Open remote workspace from plugin", exact: true })
|
||||
.click();
|
||||
await page.waitForURL(remoteWorkspaceRoute(target));
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-deck-entry-${target.serverId}:${target.workspaceId}`)
|
||||
.getByTestId("workspace-header-title"),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function openRemoteAgentFromPlugin(
|
||||
page: Page,
|
||||
target: RemoteNavigationTarget,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open remote agent from plugin", exact: true }).click();
|
||||
await page.waitForURL(remoteWorkspaceRoute(target));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${target.agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
@@ -272,14 +197,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
repoPrefix: "plugin-panel-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
const secondaryAgent = await secondary.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: secondary.repoPath,
|
||||
workspaceId: secondary.workspaceId,
|
||||
title: "Remote plugin navigation agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
@@ -287,9 +204,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
remoteServerId: secondaryDaemon.serverId,
|
||||
remoteWorkspaceId: secondary.workspaceId,
|
||||
remoteAgentId: secondaryAgent.id,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -338,15 +252,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await capture(page, testInfo, "plugin-workspace-panel-wide");
|
||||
});
|
||||
|
||||
await test.step("plugin links open browser tabs and in-app browsing is unavailable", async () => {
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await expect(page.getByText("Browser unavailable", { exact: true })).toBeVisible();
|
||||
await openPluginExternalTab(page, "link", "Plugin documentation");
|
||||
await openPluginExternalTab(page, "button", "Open external plugin URL");
|
||||
await capture(page, testInfo, "plugin-external-links-web");
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
});
|
||||
|
||||
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
|
||||
@@ -378,9 +283,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: navigationAgentId,
|
||||
remoteServerId: secondaryDaemon.serverId,
|
||||
remoteWorkspaceId: secondary.workspaceId,
|
||||
remoteAgentId: secondaryAgent.id,
|
||||
});
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
@@ -395,23 +297,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("surface navigation opens a workspace and agent on another host", async () => {
|
||||
const remoteTarget = {
|
||||
serverId: secondaryDaemon.serverId,
|
||||
workspaceId: secondary.workspaceId,
|
||||
agentId: secondaryAgent.id,
|
||||
};
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await openRemoteWorkspaceFromPlugin(page, remoteTarget);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: primary.workspaceId,
|
||||
});
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await openRemoteAgentFromPlugin(page, remoteTarget);
|
||||
});
|
||||
|
||||
await test.step("switching hosts removes commands from an uninstalled host", async () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -459,9 +344,7 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
// Pressing removed the pill from that page only. The reloaded page evaluates
|
||||
// the plugin again, and its agents snapshot contributes the pill afresh.
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
// The sidebar's Search row dismisses the compact sidebar on its way to the
|
||||
// command center, so nothing has to close it after the command runs.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
beginReplicaCacheObservation,
|
||||
finishReplicaCacheObservation,
|
||||
installReplicaCacheMeasurement,
|
||||
measureReplicaCacheRestore,
|
||||
type ReplicaCacheMeasurementReport,
|
||||
} from "../support/helpers/replica-cache-measurement";
|
||||
|
||||
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
|
||||
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
|
||||
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
|
||||
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
|
||||
const HYDRATED_SELECTOR =
|
||||
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
|
||||
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
|
||||
|
||||
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
|
||||
|
||||
measurementDescribe("Replica cache live measurement", () => {
|
||||
test("observes persistence and cached restore without mutating the daemon", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(OBSERVATION_MS + 90_000);
|
||||
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
|
||||
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
|
||||
|
||||
await installReplicaCacheMeasurement(page);
|
||||
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
|
||||
const observation = await beginReplicaCacheObservation(page);
|
||||
await page.waitForTimeout(OBSERVATION_MS);
|
||||
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
|
||||
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
|
||||
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
|
||||
|
||||
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
|
||||
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await testInfo.attach("replica-cache-measurement", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
|
||||
});
|
||||
});
|
||||
@@ -26,13 +26,6 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectRewindOption(page: Page, name: string): Promise<void> {
|
||||
const option = page.getByRole("menuitem", { name, exact: true });
|
||||
// Menus mount offscreen while their anchor and content are measured.
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(
|
||||
page: Page,
|
||||
input: { prompt: string; agentId: string; workspaceId: string },
|
||||
@@ -197,7 +190,6 @@ test.describe("Rewind sheet", () => {
|
||||
await expectUserMessageVisible(page, secondPrompt);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
await session.client.waitForFinish(session.agentId);
|
||||
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
@@ -210,7 +202,7 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await selectRewindOption(page, "Rewind conversation");
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
|
||||
@@ -229,7 +221,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await selectRewindOption(page, "Rewind files");
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
@@ -240,7 +232,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await selectRewindOption(page, "Rewind files");
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
@@ -250,7 +242,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await selectRewindOption(page, "Rewind conversation and files");
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
let server: Server;
|
||||
let appUrl: string;
|
||||
test.beforeAll(async () => {
|
||||
// Compile this separate fixture entrypoint in setup, like the main app warmup.
|
||||
// Cold Metro compilation must not consume the recovery interaction's timeout.
|
||||
test.setTimeout(120_000);
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required");
|
||||
const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`;
|
||||
const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`);
|
||||
await compiled.arrayBuffer();
|
||||
server = createServer((_request, response) => {
|
||||
response.setHeader("Content-Type", "text/html");
|
||||
response.end(
|
||||
`<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>html,body,#root{height:100%;margin:0}#root{display:flex}</style></head><body><div id="root"></div><script src="${bundle}"></script></body></html>`,
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Fixture server did not start");
|
||||
appUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function openBrokenStartup(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"workspace-layout-state",
|
||||
JSON.stringify({ state: { layoutByWorkspace: {} }, version: 1 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"paseo:last-workspace-route-selection",
|
||||
JSON.stringify({ serverId: "fixture-host", workspaceId: "broken" }),
|
||||
);
|
||||
});
|
||||
await page.goto(appUrl);
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
|
||||
}
|
||||
async function readSavedWorkspaceState(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
layout: localStorage.getItem("workspace-layout-state"),
|
||||
selection: localStorage.getItem("paseo:last-workspace-route-selection"),
|
||||
}));
|
||||
}
|
||||
async function reloadToPicker(page: Page) {
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page).toHaveURL(`${appUrl}/open-project`);
|
||||
await expect(page.getByText("Project picker", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
async function openHealthyWorkspace(page: Page) {
|
||||
await page.getByRole("link", { name: "Open healthy workspace" }).click();
|
||||
await expect(page.getByText("Healthy workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
async function breakCurrentWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Break this workspace" }).click();
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
|
||||
}
|
||||
async function captureRecoveryScreen(page: Page, testInfo: TestInfo, name: string) {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("Reload escapes a startup redirect and supports another recovery", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openBrokenStartup(page);
|
||||
const saved = await readSavedWorkspaceState(page);
|
||||
await captureRecoveryScreen(page, testInfo, "error-screen");
|
||||
await reloadToPicker(page);
|
||||
expect(await readSavedWorkspaceState(page)).toEqual(saved);
|
||||
await captureRecoveryScreen(page, testInfo, "recovered-picker");
|
||||
await openHealthyWorkspace(page);
|
||||
await breakCurrentWorkspace(page);
|
||||
await reloadToPicker(page);
|
||||
});
|
||||
|
||||
test.describe("compact error recovery", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("Reload remains reachable and returns to the picker", async ({ page }, testInfo) => {
|
||||
await openBrokenStartup(page);
|
||||
await captureRecoveryScreen(page, testInfo, "compact-error-screen");
|
||||
await reloadToPicker(page);
|
||||
await captureRecoveryScreen(page, testInfo, "compact-recovered-picker");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addScheduleHostAndReload,
|
||||
createScheduleHost,
|
||||
SECONDARY_MODEL_ID,
|
||||
SECONDARY_MODEL_LABEL,
|
||||
} from "../support/helpers/schedule-host";
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
FAKE_HOST_MODEL_ID,
|
||||
FAKE_HOST_MODEL_LABEL,
|
||||
installFakeScheduleHost,
|
||||
} from "../support/helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectSettled, expectStableHeight } from "../support/helpers/settled";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
@@ -31,11 +32,7 @@ interface ScheduleSeedClient {
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
async function seedMockSchedule(
|
||||
workspace: SeededWorkspace,
|
||||
name: string,
|
||||
model = "ten-second-stream",
|
||||
): Promise<string> {
|
||||
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
@@ -46,7 +43,7 @@ async function seedMockSchedule(
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
@@ -69,6 +66,42 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
|
||||
.catch(ignoreScheduleDeleteError);
|
||||
}
|
||||
|
||||
type FakeScheduleHostSchedule = NonNullable<
|
||||
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
|
||||
>[number];
|
||||
|
||||
function buildFakeHostSchedule(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}): FakeScheduleHostSchedule {
|
||||
const now = "2026-07-01T00:00:00.000Z";
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
prompt: "Run on the secondary host.",
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
model: FAKE_HOST_MODEL_ID,
|
||||
modeId: "load-test",
|
||||
title: input.name,
|
||||
},
|
||||
},
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nextRunAt: now,
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Schedules", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
@@ -121,23 +154,34 @@ test.describe("Schedules", () => {
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const secondary = await createScheduleHost();
|
||||
cleanupTasks.push(() => secondary.cleanup());
|
||||
const scheduleId = await seedMockSchedule(
|
||||
secondary,
|
||||
"Secondary host schedule",
|
||||
SECONDARY_MODEL_ID,
|
||||
);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(secondary, scheduleId));
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
const scheduleId = "fake-host-schedule";
|
||||
const scheduleName = "Secondary host schedule";
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
schedules: [
|
||||
buildFakeHostSchedule({
|
||||
id: scheduleId,
|
||||
name: scheduleName,
|
||||
cwd: String(fakeHost.workspace.workspaceDirectory),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addScheduleHostAndReload({
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: secondary.serverId,
|
||||
label: secondary.label,
|
||||
port: secondary.port,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
@@ -153,14 +197,14 @@ test.describe("Schedules", () => {
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
|
||||
await expect(hostTrigger).toContainText(secondary.label, { timeout: 30_000 });
|
||||
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(secondary.projectDisplayName, { timeout: 30_000 });
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(SECONDARY_MODEL_LABEL, { timeout: 30_000 });
|
||||
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(modeTrigger).toContainText("Load Test", { timeout: 30_000 });
|
||||
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addScheduleHostAndReload, createScheduleHost } from "../support/helpers/schedule-host";
|
||||
import {
|
||||
addFakeScheduleHostAndReload,
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "../support/helpers/schedule-fake-host";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
@@ -238,13 +242,20 @@ test.describe("Schedules project target", () => {
|
||||
cadenceExpression: "0 9 * * *",
|
||||
});
|
||||
|
||||
const secondary = await createScheduleHost();
|
||||
cleanupTasks.push(() => secondary.cleanup());
|
||||
await addScheduleHostAndReload({
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
serverId: secondary.serverId,
|
||||
label: secondary.label,
|
||||
port: secondary.port,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
|
||||
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
||||
@@ -263,17 +274,25 @@ test.describe("Schedules project target", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const serverId = getServerId();
|
||||
const secondary = await createScheduleHost();
|
||||
cleanupTasks.push(() => secondary.cleanup());
|
||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||
|
||||
await installFakeScheduleHost({
|
||||
page,
|
||||
port: fakePort,
|
||||
serverId: fakeHost.serverId,
|
||||
workspace: fakeHost.workspace,
|
||||
project: fakeHost,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await page.goto(buildSchedulesRoute());
|
||||
await addScheduleHostAndReload({
|
||||
await addFakeScheduleHostAndReload({
|
||||
page,
|
||||
serverId: secondary.serverId,
|
||||
label: secondary.label,
|
||||
port: secondary.port,
|
||||
serverId: fakeHost.serverId,
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
@@ -315,8 +334,8 @@ test.describe("Schedules project target", () => {
|
||||
await expectSettled(modeTrigger);
|
||||
|
||||
await hostTrigger.click();
|
||||
await page.getByTestId(`schedule-host-option-${secondary.serverId}`).click();
|
||||
await expect(hostTrigger).toContainText(secondary.label);
|
||||
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
||||
await expect(hostTrigger).toContainText("Fake host");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expect(modelTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
@@ -326,9 +345,9 @@ test.describe("Schedules project target", () => {
|
||||
|
||||
await projectTrigger.click();
|
||||
await page
|
||||
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(secondary.projectKey)}`)
|
||||
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`)
|
||||
.click();
|
||||
await expect(projectTrigger).toContainText(secondary.projectDisplayName);
|
||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText(/select model/i);
|
||||
await expectSettled(modelTrigger);
|
||||
@@ -336,7 +355,7 @@ test.describe("Schedules project target", () => {
|
||||
await expect(modeTrigger).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||
await page.getByLabel("Prompt").fill("Run on the secondary host project.");
|
||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
// it — reaching through the `.tsx` picker would drag React Native into Node.
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
const PRIMARY_LABEL = "Primary box";
|
||||
const SECONDARY_LABEL = "Secondary box";
|
||||
|
||||
@@ -27,19 +28,21 @@ async function selectHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
async function expectChronologicalTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = page.getByRole("button").filter({ hasText: NONCE });
|
||||
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Searching across hosts merges each host's chronological matching sessions.
|
||||
// Searching across hosts is the case a single-daemon spec cannot see: each host
|
||||
// ranks only its own sessions, so the merge is the only thing that can put the
|
||||
// best match first.
|
||||
test.describe("History search across hosts", () => {
|
||||
test.describe.configure({ timeout: 420_000 });
|
||||
|
||||
test("orders matches from every host chronologically and narrows to one host on demand", async ({
|
||||
test("ranks matches from every host together and narrows to one host on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
@@ -49,7 +52,8 @@ test.describe("History search across hosts", () => {
|
||||
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
|
||||
// The weak match is the newest session on the primary host and the strong
|
||||
// match is the oldest on the secondary; chronology must win over relevance.
|
||||
// match is the oldest on the secondary, so only a real cross-host merge on
|
||||
// relevance can order them correctly.
|
||||
const weakTitle = `${NONCE} Unbilled usage report`;
|
||||
const strongTitle = `${NONCE} Bill the customer`;
|
||||
const unrelatedTitle = `${NONCE} Terminal resize fix`;
|
||||
@@ -83,21 +87,22 @@ test.describe("History search across hosts", () => {
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// All hosts: the newer partial match leads.
|
||||
// All hosts: the stronger match leads despite being the older session on
|
||||
// the other daemon.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectChronologicalTitles(page, [weakTitle, strongTitle]);
|
||||
await expectRankedTitles(page, [strongTitle, weakTitle]);
|
||||
|
||||
// One host: the other daemon's stronger match drops out entirely.
|
||||
await selectHost(page, getServerId());
|
||||
await expectChronologicalTitles(page, [weakTitle]);
|
||||
await expectRankedTitles(page, [weakTitle]);
|
||||
|
||||
await selectHost(page, secondaryDaemon.serverId);
|
||||
await expectChronologicalTitles(page, [strongTitle]);
|
||||
await expectRankedTitles(page, [strongTitle]);
|
||||
|
||||
// Back to all hosts, and clearing the query returns every seeded session.
|
||||
await selectHost(page, ALL_HOSTS_OPTION_ID);
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
const rows = page.getByRole("button").filter({ hasText: NONCE });
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(3, { timeout: 30_000 });
|
||||
} finally {
|
||||
await primaryWorkspace?.cleanup().catch(() => undefined);
|
||||
@@ -135,7 +140,7 @@ test.describe("History search across hosts", () => {
|
||||
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectChronologicalTitles(page, [reachableTitle]);
|
||||
await expectRankedTitles(page, [reachableTitle]);
|
||||
} finally {
|
||||
await workspace.cleanup().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
resetSeededPageState,
|
||||
} from "../support/helpers/archive-tab";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
|
||||
/**
|
||||
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
|
||||
* can only reach this spec's sessions. The daemon is shared with the rest of
|
||||
@@ -17,9 +19,9 @@ import {
|
||||
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
const TITLES = {
|
||||
billing: `${NONCE} main Add Stripe billing`,
|
||||
unbilled: `${NONCE} main Unbilled usage report`,
|
||||
terminal: `${NONCE} main Terminal resize fix`,
|
||||
billing: `${NONCE} Add Stripe billing`,
|
||||
unbilled: `${NONCE} Unbilled usage report`,
|
||||
terminal: `${NONCE} Terminal resize fix`,
|
||||
} as const;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
@@ -27,7 +29,7 @@ async function search(page: Page, query: string): Promise<void> {
|
||||
}
|
||||
|
||||
function rowTitles(page: Page) {
|
||||
return page.getByRole("button");
|
||||
return page.locator(AGENT_ROW);
|
||||
}
|
||||
|
||||
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
|
||||
@@ -38,75 +40,6 @@ async function expectVisibleTitles(page: Page, titles: string[]): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyChronologicalFiltering(page: Page): Promise<void> {
|
||||
await test.step("narrows history while preserving chronological grouping", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
|
||||
await test.step("keeps newer partial matches before older stronger matches", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing]);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyExactAndTypoHighlights(page: Page): Promise<void> {
|
||||
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.getByRole("button").filter({ hasText: NONCE }).first();
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
const typoRow = page.getByRole("button").filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyAllSearchFieldHighlights(page: Page): Promise<void> {
|
||||
await test.step("highlights workspace, agent, project and branch independently", async () => {
|
||||
await search(page, `${NONCE} main`);
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
const row = rowTitles(page).filter({ hasText: NONCE }).first();
|
||||
for (const field of ["workspace", "title", "project", "branch"]) {
|
||||
await expect(
|
||||
row.getByTestId(new RegExp(`^agent-row-${field}-`)).getByText(/^main$/i),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Yesterday", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyEmptySearchAndRecovery(page: Page): Promise<void> {
|
||||
await test.step("distinguishes no matches from empty history", async () => {
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("History search", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
@@ -115,11 +48,10 @@ test.describe("History search", () => {
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("main-sessions-search-");
|
||||
tempRepo = await createTempGitRepo("sessions-search-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
title: "Main history",
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
@@ -127,7 +59,7 @@ test.describe("History search", () => {
|
||||
projectId = created.workspace.projectId;
|
||||
const workspaceId = created.workspace.id;
|
||||
|
||||
for (const title of [TITLES.terminal, TITLES.billing, TITLES.unbilled]) {
|
||||
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
|
||||
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
|
||||
}
|
||||
});
|
||||
@@ -138,26 +70,55 @@ test.describe("History search", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 1000 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`filters chronological history and highlights matches at ${viewport.width}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
await page.setViewportSize(viewport);
|
||||
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await verifyChronologicalFiltering(page);
|
||||
await test.step("narrows history and restores chronological grouping", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await verifyRecencyBeforeMatchStrength(page);
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await verifyExactAndTypoHighlights(page);
|
||||
|
||||
await verifyAllSearchFieldHighlights(page);
|
||||
|
||||
await verifyEmptySearchAndRecovery(page);
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await test.step("ranks whole-word matches above substrings", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("distinguishes no matches from empty history", async () => {
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,47 +21,52 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("visits host settings and opens the label editor", async ({ page }) => {
|
||||
test("connections section shows the seeded connection endpoint", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const port = getE2EDaemonPort();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await test.step("connections section shows the seeded connection endpoint", async () => {
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
await test.step("agents section shows the inject MCP toggle", async () => {
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
await test.step("providers section shows the providers card", async () => {
|
||||
await expectHostProvidersCard(page, serverId);
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
await test.step("host section shows the host label and restart/remove action cards", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
await test.step("clicking the label pencil reveals the inline editor", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await test.step("host section does not render daemon lifecycle controls for a remote daemon", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
await test.step("settings sidebar exposes the flat App and Host section rows", async () => {
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
test("agents section shows the inject MCP toggle", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
|
||||
test("providers section shows the providers card", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expectHostProvidersCard(page, serverId);
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
|
||||
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("a failed remote daemon update remains visible in the host UI", async ({
|
||||
@@ -89,6 +94,40 @@ test.describe("Settings host page", () => {
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
|
||||
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -72,9 +72,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHeader(page, "Appearance");
|
||||
await expectAppearanceContent(page);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
@@ -82,13 +79,19 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
});
|
||||
|
||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
@@ -112,6 +115,20 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("pressing Escape closes settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -138,45 +155,66 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
|
||||
await test.step("open app details and return to the list", async () => {
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
});
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
await test.step("open host details and return through the list", async () => {
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await goBackInSettings(page);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("about"));
|
||||
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
|
||||
test("host picker settings opens Overview and backs through the settings list", async ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(workspace.client, {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(workspace.client, {
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-b-${Date.now()}`,
|
||||
@@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(workspace.client, {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-composer-height-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(workspace.client, {
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-composer-height-b-${Date.now()}`,
|
||||
@@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(workspace.client, {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(workspace.client, {
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-b-${Date.now()}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// Model B sidebar shape: every project — git or non-git, single- or
|
||||
// multi-workspace — renders as the same expandable parent, the deepest sidebar
|
||||
@@ -95,9 +96,8 @@ test.describe("Model B sidebar shape", () => {
|
||||
try {
|
||||
// Open the workspace and materialize both an agent tab and a terminal tab.
|
||||
await gotoWorkspace(page, mock.workspaceId);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
|
||||
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
await expect(
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
selectSidebarStatusGrouping,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const TITLE = "Linux desktop sandbox launch support";
|
||||
|
||||
async function seedChangedWorkspace() {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-title-",
|
||||
title: TITLE,
|
||||
repo: { withRemote: true },
|
||||
});
|
||||
try {
|
||||
await rm(path.join(workspace.repoPath, "remote.git"), { recursive: true });
|
||||
await workspace.client.renameProject(workspace.projectId, "Paseo");
|
||||
await writeFile(path.join(workspace.repoPath, "README.md"), "Changed line\n".repeat(12345));
|
||||
await workspace.client.checkoutRefresh(workspace.repoPath);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { entries } = await workspace.client.fetchWorkspaces();
|
||||
return entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat?.additions;
|
||||
})
|
||||
.toBe(12345);
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceRow(page: Page) {
|
||||
return page.getByRole("button", { name: new RegExp(TITLE) }).filter({ visible: true });
|
||||
}
|
||||
|
||||
async function titleWidth(row: Locator) {
|
||||
return row
|
||||
.getByText(TITLE, { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
async function toggleTrailing(page: Page, label: "Diff stats" | "Last activity", touch = false) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Bottom sheet backdrop", exact: true }),
|
||||
).toHaveCount(0);
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
|
||||
async function expectTouchTitleWidth(page: Page) {
|
||||
const row = workspaceRow(page);
|
||||
const withDiff = await titleWidth(row);
|
||||
await toggleTrailing(page, "Diff stats", true);
|
||||
const withoutStats = await titleWidth(row);
|
||||
expect(withDiff).toBeCloseTo(withoutStats, 0);
|
||||
await toggleTrailing(page, "Last activity", true);
|
||||
expect(await titleWidth(row)).toBeCloseTo(withoutStats, 0);
|
||||
await toggleTrailing(page, "Diff stats", true);
|
||||
}
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedChangedWorkspace();
|
||||
});
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(workspaceRow(page)).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
|
||||
}
|
||||
|
||||
async function pinWorkspaceForLayout(page: Page) {
|
||||
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
}
|
||||
|
||||
async function moveWorkspaceToStatusGrouping(page: Page) {
|
||||
await workspace.client.setWorkspacePinned(workspace.workspaceId, false);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
}
|
||||
|
||||
async function openDesktopWorkspaceList(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
const row = workspaceRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
|
||||
return titleWidth(row);
|
||||
}
|
||||
|
||||
async function expectHoverKeepsTitleWidth(page: Page, width: number, testInfo: TestInfo) {
|
||||
const row = workspaceRow(page);
|
||||
await row.hover();
|
||||
await expect(row.getByLabel("Workspace actions", { exact: true })).toBeVisible();
|
||||
expect(await titleWidth(row)).toBeCloseTo(width, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-hover.png") });
|
||||
}
|
||||
|
||||
async function expectShortcutHintsKeepTitleWidth(page: Page, width: number, testInfo: TestInfo) {
|
||||
const row = workspaceRow(page);
|
||||
await page.keyboard.down("Alt");
|
||||
await expect(row.getByText("1", { exact: true })).toBeVisible();
|
||||
expect(await titleWidth(row)).toBeCloseTo(width, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-shortcuts.png") });
|
||||
await page.keyboard.up("Alt");
|
||||
await expect(row.getByText("1", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectDisablingStatsExpandsTitle(page: Page, width: number) {
|
||||
await toggleTrailing(page, "Diff stats");
|
||||
expect(await titleWidth(workspaceRow(page))).toBeGreaterThan(width);
|
||||
}
|
||||
|
||||
test("touch titles use the space occupied by hidden stats in every grouping", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openTouchWorkspaceList(page, testInfo);
|
||||
await expectTouchTitleWidth(page);
|
||||
await pinWorkspaceForLayout(page);
|
||||
await expectTouchTitleWidth(page);
|
||||
await moveWorkspaceToStatusGrouping(page);
|
||||
await expectTouchTitleWidth(page);
|
||||
});
|
||||
|
||||
test("desktop hover and shortcut hints preserve title width", async ({ page }, testInfo) => {
|
||||
const width = await openDesktopWorkspaceList(page);
|
||||
await expectHoverKeepsTitleWidth(page, width, testInfo);
|
||||
await expectShortcutHintsKeepTitleWidth(page, width, testInfo);
|
||||
await expectDisablingStatsExpandsTitle(page, width);
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
type WireDirection = "sent" | "received";
|
||||
type WirePhase = "startup" | "workspace_clicks";
|
||||
|
||||
interface ParsedWireMessage {
|
||||
type: string | null;
|
||||
requestId: string | null;
|
||||
entryCount: number | null;
|
||||
hasMore: boolean | null;
|
||||
providerEntries: ProviderSnapshotWireEntry[] | null;
|
||||
}
|
||||
|
||||
type WireFrameRecord = ParsedWireMessage & {
|
||||
phase: WirePhase;
|
||||
direction: WireDirection;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
interface ProviderSnapshotWireEntry {
|
||||
provider: string;
|
||||
status: string | null;
|
||||
modelCount: number;
|
||||
modeCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface WebSocketFrameEvent {
|
||||
requestId: string;
|
||||
response: {
|
||||
opcode: number;
|
||||
payloadData: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WireSummary {
|
||||
totalFrames: number;
|
||||
totalBytes: number;
|
||||
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
||||
byPhase: Record<
|
||||
WirePhase,
|
||||
{
|
||||
frames: number;
|
||||
bytes: number;
|
||||
byType: Array<{ type: string; frames: number; bytes: number }>;
|
||||
rpcCounts: Array<{ requestType: string; count: number }>;
|
||||
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
||||
}
|
||||
>;
|
||||
fetchPages: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
entries: number | null;
|
||||
hasMore: boolean | null;
|
||||
bytes: number;
|
||||
}>;
|
||||
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
||||
providerSnapshots: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
totalModels: number;
|
||||
totalModes: number;
|
||||
bytes: number;
|
||||
providers: ProviderSnapshotWireEntry[];
|
||||
}>;
|
||||
providerSnapshotTotals: Array<{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: string[];
|
||||
}>;
|
||||
fork: {
|
||||
sourceHome: string | null;
|
||||
targetHome: string | null;
|
||||
copiedFiles: number | null;
|
||||
copiedBytes: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
function extractWorkspaceTestIds(elements: Element[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const element of elements.slice(0, 3)) {
|
||||
const value = element.getAttribute("data-testid");
|
||||
if (value) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class WireMonitor {
|
||||
private phase: WirePhase = "startup";
|
||||
private session: CDPSession | null = null;
|
||||
readonly records: WireFrameRecord[] = [];
|
||||
|
||||
async start(page: Page): Promise<void> {
|
||||
this.session = await page.context().newCDPSession(page);
|
||||
await this.session.send("Network.enable");
|
||||
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
||||
this.record("sent", event);
|
||||
});
|
||||
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
||||
this.record("received", event);
|
||||
});
|
||||
}
|
||||
|
||||
setPhase(phase: WirePhase): void {
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
hasCompletedStartupFetches(): boolean {
|
||||
return (
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_agents_response" &&
|
||||
record.hasMore === false,
|
||||
) &&
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_workspaces_response" &&
|
||||
record.hasMore === false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
||||
return {
|
||||
totalFrames: this.records.length,
|
||||
totalBytes: sumBytes(this.records),
|
||||
byDirection: {
|
||||
sent: summarizeDirection(this.records, "sent"),
|
||||
received: summarizeDirection(this.records, "received"),
|
||||
},
|
||||
byPhase: {
|
||||
startup: summarizePhase(this.records, "startup"),
|
||||
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
||||
},
|
||||
fetchPages: this.records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
(record.type === "fetch_agents_response" ||
|
||||
record.type === "fetch_workspaces_response"),
|
||||
)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
entries: record.entryCount,
|
||||
hasMore: record.hasMore,
|
||||
bytes: record.bytes,
|
||||
})),
|
||||
clickedWorkspaces,
|
||||
providerSnapshots: this.records
|
||||
.filter((record) => record.direction === "received" && record.providerEntries)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
totalModels: sumProviderModels(record.providerEntries ?? []),
|
||||
totalModes: sumProviderModes(record.providerEntries ?? []),
|
||||
bytes: record.bytes,
|
||||
providers: record.providerEntries ?? [],
|
||||
})),
|
||||
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
||||
fork: {
|
||||
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
||||
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
||||
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
||||
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
||||
if (event.response.opcode !== 1) {
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData),
|
||||
type: `opcode:${event.response.opcode}`,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
||||
...parseWireMessage(event.response.payloadData),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(payloadData) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
const envelope = parsed as {
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const unwrapped =
|
||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? envelope.message
|
||||
: parsed;
|
||||
const message = unwrapped as {
|
||||
type?: unknown;
|
||||
requestId?: unknown;
|
||||
payload?: {
|
||||
requestId?: unknown;
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
pageInfo?: { hasMore?: unknown };
|
||||
};
|
||||
};
|
||||
const entries = readMessageEntries(message.payload);
|
||||
const hasMore = readMessageHasMore(message.payload);
|
||||
const messageType = typeof message.type === "string" ? message.type : null;
|
||||
const providerEntries = isProviderSnapshotMessage(messageType)
|
||||
? parseProviderSnapshotEntries(message.payload)
|
||||
: null;
|
||||
return {
|
||||
type: messageType,
|
||||
requestId: readMessageRequestId(message),
|
||||
entryCount: entries ? entries.length : null,
|
||||
hasMore,
|
||||
providerEntries,
|
||||
};
|
||||
} catch {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function readMessageRequestId(message: {
|
||||
requestId?: unknown;
|
||||
payload?: { requestId?: unknown };
|
||||
}): string | null {
|
||||
if (typeof message.requestId === "string") {
|
||||
return message.requestId;
|
||||
}
|
||||
if (typeof message.payload?.requestId === "string") {
|
||||
return message.payload.requestId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
||||
return (
|
||||
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageEntries(
|
||||
payload:
|
||||
| {
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
): unknown[] | null {
|
||||
if (Array.isArray(payload?.entries)) {
|
||||
return payload.entries;
|
||||
}
|
||||
if (Array.isArray(payload?.agents)) {
|
||||
return payload.agents;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMessageHasMore(
|
||||
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
||||
): boolean | null {
|
||||
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
||||
}
|
||||
|
||||
function emptyParsedWireMessage(): ParsedWireMessage {
|
||||
return {
|
||||
type: null,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = (payload as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const provider = (entry as { provider?: unknown }).provider;
|
||||
if (typeof provider !== "string") {
|
||||
continue;
|
||||
}
|
||||
const models = (entry as { models?: unknown }).models;
|
||||
const modes = (entry as { modes?: unknown }).modes;
|
||||
const status = (entry as { status?: unknown }).status;
|
||||
providerEntries.push({
|
||||
provider,
|
||||
status: typeof status === "string" ? status : null,
|
||||
modelCount: Array.isArray(models) ? models.length : 0,
|
||||
modeCount: Array.isArray(modes) ? modes.length : 0,
|
||||
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
||||
});
|
||||
}
|
||||
|
||||
return providerEntries.length > 0 ? providerEntries : null;
|
||||
}
|
||||
|
||||
function sumBytes(records: WireFrameRecord[]): number {
|
||||
return records.reduce((sum, record) => sum + record.bytes, 0);
|
||||
}
|
||||
|
||||
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
||||
const selected = records.filter((record) => record.direction === direction);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
||||
const selected = records.filter((record) => record.phase === phase);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
byType: summarizeByType(selected),
|
||||
rpcCounts: summarizeRpcCounts(selected),
|
||||
rpcs: summarizeRpcs(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeByType(records: WireFrameRecord[]): Array<{
|
||||
type: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
}> {
|
||||
const byType = new Map<string, { frames: number; bytes: number }>();
|
||||
for (const record of records) {
|
||||
const type = record.type ?? "unknown";
|
||||
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
||||
current.frames += 1;
|
||||
current.bytes += record.bytes;
|
||||
byType.set(type, current);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, value]) => Object.assign({ type }, value))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
||||
const responsesByRequestId = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
||||
}
|
||||
|
||||
return records
|
||||
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
||||
.map((record) => ({
|
||||
requestType: record.type ?? "unknown",
|
||||
requestId: record.requestId ?? "",
|
||||
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeRpcCounts(
|
||||
records: WireFrameRecord[],
|
||||
): Array<{ requestType: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([requestType, count]) => ({ requestType, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
||||
);
|
||||
}
|
||||
|
||||
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
||||
}
|
||||
|
||||
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
||||
}
|
||||
|
||||
function summarizeProviderSnapshots(
|
||||
records: WireFrameRecord[],
|
||||
): WireSummary["providerSnapshotTotals"] {
|
||||
const byProvider = new Map<
|
||||
string,
|
||||
{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.providerEntries) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of record.providerEntries) {
|
||||
const key = `${record.phase}:${entry.provider}`;
|
||||
const current = byProvider.get(key) ?? {
|
||||
phase: record.phase,
|
||||
provider: entry.provider,
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
maxModels: 0,
|
||||
maxModes: 0,
|
||||
statuses: new Set<string>(),
|
||||
};
|
||||
current.frames += 1;
|
||||
current.bytes += entry.bytes;
|
||||
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
||||
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
||||
if (entry.status) {
|
||||
current.statuses.add(entry.status);
|
||||
}
|
||||
byProvider.set(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byProvider.values()]
|
||||
.map((entry) => ({
|
||||
phase: entry.phase,
|
||||
provider: entry.provider,
|
||||
frames: entry.frames,
|
||||
bytes: entry.bytes,
|
||||
maxModels: entry.maxModels,
|
||||
maxModes: entry.maxModes,
|
||||
statuses: [...entry.statuses].sort(),
|
||||
}))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
||||
await testInfo.attach("startup-wire-metrics.json", {
|
||||
body: JSON.stringify(summary, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("ad hoc startup wire metrics", () => {
|
||||
test.skip(
|
||||
process.env.E2E_WIRE_METRICS !== "1",
|
||||
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
||||
);
|
||||
|
||||
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const monitor = new WireMonitor();
|
||||
await monitor.start(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page, 120_000);
|
||||
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const workspaceTestIds = await page
|
||||
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
||||
.evaluateAll(extractWorkspaceTestIds);
|
||||
|
||||
monitor.setPhase("workspace_clicks");
|
||||
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
||||
for (const testId of workspaceTestIds) {
|
||||
const row = page.getByTestId(testId);
|
||||
if (!(await row.isVisible().catch(() => false))) {
|
||||
continue;
|
||||
}
|
||||
const beforeFrames = monitor.records.length;
|
||||
const beforeBytes = sumBytes(monitor.records);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1_000);
|
||||
clickedWorkspaces.push({
|
||||
testId,
|
||||
frames: monitor.records.length - beforeFrames,
|
||||
bytes: sumBytes(monitor.records) - beforeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = monitor.summarize(clickedWorkspaces);
|
||||
await attachSummary(testInfo, summary);
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
||||
|
||||
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
||||
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withAcpStreamingMarkdown,
|
||||
requestAcpMarkdown,
|
||||
expectAcpMarkdown,
|
||||
reloadAcpMarkdown,
|
||||
} from "../support/helpers/acp-streaming-markdown";
|
||||
import {
|
||||
expectFinishedMarkdown,
|
||||
expectReloadedMarkdown,
|
||||
expectUnfinishedBold,
|
||||
expectUnfinishedLink,
|
||||
requestStreamingMarkdown,
|
||||
withStreamingMarkdown,
|
||||
} from "../support/helpers/streaming-markdown";
|
||||
|
||||
test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withStreamingMarkdown(page, testInfo, async (agent) => {
|
||||
await requestStreamingMarkdown(agent);
|
||||
await expectUnfinishedBold(page);
|
||||
await expectUnfinishedLink(page, agent, testInfo);
|
||||
await expectFinishedMarkdown(page, agent, testInfo);
|
||||
await expectReloadedMarkdown(page);
|
||||
});
|
||||
});
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
test(`ACP plugin chunks preserve Markdown and separate turns at ${width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await withAcpStreamingMarkdown(page, async (agent) => {
|
||||
await requestAcpMarkdown(agent);
|
||||
await expectAcpMarkdown(page, 1);
|
||||
await requestAcpMarkdown(agent);
|
||||
await expectAcpMarkdown(page, 2);
|
||||
await reloadAcpMarkdown(page, testInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user