mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 14:22:42 +00:00
Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a134873d47 | ||
|
|
4274b2be0d | ||
|
|
b9ac1d4982 | ||
|
|
d91171954b | ||
|
|
f232d23e9a | ||
|
|
68e722ec2e | ||
|
|
3b41183606 | ||
|
|
690f5298cb | ||
|
|
68938b668e | ||
|
|
560e478ef5 | ||
|
|
4965af219f | ||
|
|
940dbfd242 | ||
|
|
4340607093 | ||
|
|
849a876bce | ||
|
|
f4ba16a0b3 | ||
|
|
9d010211a2 | ||
|
|
8b5201fedf | ||
|
|
05874e289a | ||
|
|
d0a30ed4da | ||
|
|
cf4509631b | ||
|
|
b85b44aea1 | ||
|
|
7bb7d4ed08 | ||
|
|
dd5df9ec61 | ||
|
|
8de52a3c99 | ||
|
|
dffde6ae8b | ||
|
|
a43c8d888f | ||
|
|
792715e762 | ||
|
|
827178df90 | ||
|
|
c54f20e53e | ||
|
|
c7e7e52b0c | ||
|
|
c481ecf3e1 | ||
|
|
dfc9add774 | ||
|
|
da48803a43 | ||
|
|
7f5d32cdd5 | ||
|
|
dec2d861ec | ||
|
|
30178c4f58 | ||
|
|
52d345db7f | ||
|
|
a50a476009 | ||
|
|
6ca001c3e4 | ||
|
|
78a0f093eb | ||
|
|
d7b7016cc9 | ||
|
|
e1c769c018 | ||
|
|
c081e0350d | ||
|
|
e2de6df2e2 | ||
|
|
c906c2f4a4 | ||
|
|
fffd76e8fb | ||
|
|
d2e2154a8a | ||
|
|
cb9654a65c | ||
|
|
3dc17b7f30 | ||
|
|
76a9781ba5 | ||
|
|
a272a22d7e | ||
|
|
26f227bed9 | ||
|
|
aa3ffaeb6c | ||
|
|
513f2a9ea2 | ||
|
|
8cd989529e | ||
|
|
04c3e003f8 | ||
|
|
e68553f759 | ||
|
|
dc9799f6fc | ||
|
|
43a2a7969c | ||
|
|
90d978ab68 | ||
|
|
a048094da4 | ||
|
|
315803688d | ||
|
|
bf4dc22c2f | ||
|
|
5c9f767d51 | ||
|
|
84304b5532 | ||
|
|
28507224dc | ||
|
|
db4fd33406 | ||
|
|
e3c853df58 | ||
|
|
ea7a741851 | ||
|
|
c67b7158b4 | ||
|
|
91dc927335 | ||
|
|
49f9cec6be | ||
|
|
e07da55f81 | ||
|
|
27d6a71856 | ||
|
|
bbf8cce3f7 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd | ||
|
|
f4efdbded7 | ||
|
|
c976e2e5a5 | ||
|
|
b2bb512b31 | ||
|
|
067937bace | ||
|
|
d615d3d424 | ||
|
|
fbe5005aa1 | ||
|
|
e998e0a082 | ||
|
|
ae42b0afb0 | ||
|
|
829cc5e173 | ||
|
|
48384cafdf | ||
|
|
2c7b38bc7e | ||
|
|
222d45a2fc | ||
|
|
373da70694 | ||
|
|
c3e1e084a0 | ||
|
|
9978988e35 | ||
|
|
8768500fc0 | ||
|
|
89073d4c5c | ||
|
|
ec43e90670 | ||
|
|
897a0abb1a | ||
|
|
c4771ca467 | ||
|
|
05bfffad41 | ||
|
|
faee1cd952 | ||
|
|
ba4595d4e8 | ||
|
|
34c9fd03c9 | ||
|
|
9a3f3a0dc6 | ||
|
|
d6861f81e5 | ||
|
|
3054f80050 | ||
|
|
6d37f7fd9f | ||
|
|
94ab368e4e | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd | ||
|
|
7f7e60bcbb | ||
|
|
8b6e9447f4 | ||
|
|
83f9fba209 | ||
|
|
b2ce2bcb83 | ||
|
|
135a3b4c9e | ||
|
|
2c8e8a8268 | ||
|
|
ee7949ae25 | ||
|
|
d636abd7a4 | ||
|
|
e9d32a17d6 | ||
|
|
0fd1db5748 | ||
|
|
9696f42264 | ||
|
|
3cc4ae2866 | ||
|
|
0e965bcd7a | ||
|
|
2f3272490e | ||
|
|
7c1958f5b0 | ||
|
|
ed9b51f94e | ||
|
|
5d70ab2ab8 | ||
|
|
dc682adb2a | ||
|
|
92b92a81c8 | ||
|
|
bb4763b38c | ||
|
|
047f40e623 | ||
|
|
1e4ba65c6d | ||
|
|
6830c46e9a | ||
|
|
c7db3c5a8c | ||
|
|
a8a8da0472 | ||
|
|
6ce6f4c961 | ||
|
|
72e3d6957a | ||
|
|
46529146ac | ||
|
|
4f710d9930 | ||
|
|
0f20e6dfe4 | ||
|
|
0e8a71a5fe | ||
|
|
a78949d1d4 | ||
|
|
64b1a62ed3 | ||
|
|
0eac75be7d | ||
|
|
7557992dff | ||
|
|
4251575950 | ||
|
|
f9fb992dcb | ||
|
|
5ae0c5b83b | ||
|
|
4be34f05ef | ||
|
|
7d74916f8e | ||
|
|
80b4387bc6 | ||
|
|
326a37cc89 | ||
|
|
26e0273acb | ||
|
|
cb90806045 | ||
|
|
d8dd189b94 | ||
|
|
0474c3e0af | ||
|
|
c5c0536b46 | ||
|
|
b21c004ffe | ||
|
|
6215e08efe | ||
|
|
c3bcad687d | ||
|
|
174055a63c | ||
|
|
0aca3b605a | ||
|
|
0c5f472da1 | ||
|
|
13979e9c38 | ||
|
|
77c5c8f17e | ||
|
|
836f1a9c23 | ||
|
|
fdb5540a94 | ||
|
|
799c91b39d | ||
|
|
d63132a37d | ||
|
|
cdc669fde6 | ||
|
|
d1a53255fc | ||
|
|
d1b705a0cd | ||
|
|
0d7760e68b | ||
|
|
03a54a171d | ||
|
|
d0e1edca7f | ||
|
|
2d7733cd65 | ||
|
|
a7ce602c83 | ||
|
|
fa93c4290e | ||
|
|
192c517835 | ||
|
|
f22a37e613 | ||
|
|
772ff8d6f3 | ||
|
|
0110302b6f | ||
|
|
4006f974fb | ||
|
|
6f2195904b | ||
|
|
33a52d0ae7 | ||
|
|
16d932651c | ||
|
|
d7c7044dfc | ||
|
|
b8e24677e1 | ||
|
|
1f3bb0239b | ||
|
|
f7e68e32d1 | ||
|
|
94fc4e5d49 | ||
|
|
7bcf167862 | ||
|
|
8598b6aab9 | ||
|
|
ae1cf642bb | ||
|
|
2ee0191ea1 | ||
|
|
f3917173a9 | ||
|
|
92504cd525 | ||
|
|
726067b437 | ||
|
|
ec4e81ce0f | ||
|
|
bc385b5930 | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f | ||
|
|
433e67b18b | ||
|
|
d311797653 | ||
|
|
0b3d82a469 | ||
|
|
509a3e17bd | ||
|
|
19bd6e5909 | ||
|
|
16bfff8e0f | ||
|
|
fdf3b4b47f | ||
|
|
96ea6698d7 | ||
|
|
85cd969321 | ||
|
|
c172076bff | ||
|
|
613cbbe9ef | ||
|
|
da8c1b5c94 | ||
|
|
18aaec2772 | ||
|
|
158525624f | ||
|
|
47171b419b | ||
|
|
f809651876 |
@@ -34,6 +34,7 @@ hub:
|
||||
- "packages/server/src/server/hub/**"
|
||||
|
||||
server:
|
||||
- "plugins/**"
|
||||
- "packages/server/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
@@ -52,7 +53,7 @@ app:
|
||||
sdk:
|
||||
- "packages/plugin/**"
|
||||
- "plugin-examples/**"
|
||||
- "public-docs/plugins/v0.8/**"
|
||||
- "public-docs/plugins/**"
|
||||
- "packages/client/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
|
||||
+86
-29
@@ -52,7 +52,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
@@ -117,9 +117,21 @@ jobs:
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Verify built-in plugins from built output
|
||||
run: node --test scripts/builtin-plugins-dist.test.mjs
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run website tests
|
||||
run: npx vitest run packages/website/src --bail=1
|
||||
|
||||
- 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
|
||||
@@ -163,6 +175,10 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
@@ -174,11 +190,42 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -201,12 +248,35 @@ jobs:
|
||||
|
||||
- name: Install virtual display
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Verify Windows daemon process control
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
|
||||
|
||||
- name: Run desktop lifecycle E2E (Linux)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
|
||||
|
||||
- name: Upload desktop lifecycle screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-lifecycle
|
||||
path: ${{ runner.temp }}/desktop-lifecycle
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run desktop lifecycle E2E (Windows)
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run desktop renderer E2E
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
|
||||
@@ -226,23 +296,6 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
desktop-tests-windows:
|
||||
name: desktop-tests (windows-latest)
|
||||
needs: changes
|
||||
@@ -296,7 +349,7 @@ jobs:
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
@@ -304,9 +357,6 @@ jobs:
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Typecheck plugin examples
|
||||
run: npm run typecheck --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
@@ -355,6 +405,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -440,14 +493,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
# The local runner builds its dependencies and shards its own E2E files.
|
||||
- name: Run CLI E2E tests
|
||||
run: npm run test:local --workspace=@getpaseo/cli
|
||||
|
||||
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
|
||||
# shortest E2E shard, after its runner has built the server stack.
|
||||
- name: Run CLI source tests
|
||||
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/cli
|
||||
|
||||
cli-tests-2:
|
||||
name: cli-tests (shard 2/3)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Desktop Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
concurrency:
|
||||
group: desktop-packages-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install package smoke dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
|
||||
- name: Build Linux desktop artifacts
|
||||
# Smoke package contents and installation without spending minutes on xz compression.
|
||||
run: |
|
||||
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Smoke installed artifacts with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
if unshare --user --map-root-user --net true; then
|
||||
echo "Expected the launching user's namespace probe to be denied"
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y ./packages/desktop/release/*.deb
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
|
||||
|
||||
- name: Smoke sandbox-preserving portable launches
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
unshare --user --map-root-user --net true
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
|
||||
|
||||
- name: Smoke installed RPM with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
sudo dpkg --remove paseo
|
||||
# Ubuntu supplies the runtime libraries under Debian package names.
|
||||
# RPM still performs the real payload installation and postinstall.
|
||||
sudo rpm --install --nodeps packages/desktop/release/*.rpm
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -102,8 +102,10 @@ jobs:
|
||||
include:
|
||||
- runner: macos-14
|
||||
electron_arch: arm64
|
||||
node_heap_mb: 4096
|
||||
- runner: macos-15-intel
|
||||
electron_arch: x64
|
||||
node_heap_mb: 8192
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -161,6 +163,10 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
|
||||
# the last module in the web bundle. Node's default heap is sized from
|
||||
# runner RAM, which is not enough on the Intel runner.
|
||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
@@ -185,16 +191,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -261,7 +259,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
@@ -278,7 +276,7 @@ jobs:
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure()
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-smoke-linux-x64
|
||||
@@ -294,16 +292,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -400,16 +390,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -51,7 +30,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/build-daemon-web-ui.mjs"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,8 +12,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -110,8 +89,6 @@ jobs:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
|
||||
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
+284
-63
@@ -1,91 +1,312 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
|
||||
|
||||
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
|
||||
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
|
||||
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
|
||||
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
|
||||
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
|
||||
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
|
||||
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
|
||||
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
|
||||
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
|
||||
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
|
||||
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
|
||||
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
|
||||
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
|
||||
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
|
||||
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
|
||||
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
|
||||
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
|
||||
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
|
||||
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
|
||||
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
|
||||
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
|
||||
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
|
||||
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
|
||||
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
|
||||
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
|
||||
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
|
||||
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
|
||||
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
|
||||
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
|
||||
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
|
||||
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
|
||||
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
|
||||
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
|
||||
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
|
||||
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
|
||||
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
|
||||
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
|
||||
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
|
||||
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
|
||||
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
|
||||
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
|
||||
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
|
||||
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
|
||||
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
|
||||
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
|
||||
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
|
||||
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
|
||||
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
|
||||
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
|
||||
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
|
||||
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
|
||||
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
|
||||
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
|
||||
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
|
||||
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
|
||||
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
|
||||
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
|
||||
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
|
||||
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
|
||||
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
|
||||
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
|
||||
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589))
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650))
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765))
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861))
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956))
|
||||
- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756))
|
||||
- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958))
|
||||
- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975))
|
||||
- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942))
|
||||
- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971))
|
||||
- Added `openExternalUrl()` and `<ExternalLink>` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972))
|
||||
- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972))
|
||||
- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger))
|
||||
- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork))
|
||||
- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675))
|
||||
- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati))
|
||||
- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736))
|
||||
- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598))
|
||||
- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844))
|
||||
- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575))
|
||||
|
||||
### Improved
|
||||
|
||||
- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742))
|
||||
- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676))
|
||||
- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007))
|
||||
- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863))
|
||||
- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863))
|
||||
- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958))
|
||||
- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838))
|
||||
- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442))
|
||||
- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844))
|
||||
- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839))
|
||||
- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824))
|
||||
- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902))
|
||||
- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946))
|
||||
- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973))
|
||||
- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013))
|
||||
- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758))
|
||||
- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl))
|
||||
- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447))
|
||||
- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575))
|
||||
- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926))
|
||||
- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756))
|
||||
- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991))
|
||||
- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862))
|
||||
- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596))
|
||||
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
|
||||
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
|
||||
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
|
||||
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
|
||||
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
|
||||
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
|
||||
- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861))
|
||||
- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925))
|
||||
- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898))
|
||||
|
||||
## 0.8.0 - 2026-09-10
|
||||
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
|
||||
|
||||
### Added
|
||||
|
||||
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
|
||||
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
|
||||
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
|
||||
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
|
||||
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
|
||||
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
|
||||
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
|
||||
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
|
||||
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
|
||||
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
|
||||
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
|
||||
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
|
||||
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
|
||||
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
|
||||
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
|
||||
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
|
||||
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
|
||||
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
|
||||
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
|
||||
@@ -106,9 +106,14 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
## Release branches
|
||||
|
||||
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
|
||||
When the user says "this goes to next", create or
|
||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
@@ -120,6 +125,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
|
||||
- **Always run typecheck and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||
- `npm run build:client` — rebuild protocol and client declarations.
|
||||
|
||||
+61
-99
@@ -1,142 +1,104 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thank you for taking the time to contribute to Paseo.
|
||||
This guide is here to save us both time and help you find a useful way to contribute.
|
||||
|
||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
**Paseo has a lean, opinionated core built to be extended.**
|
||||
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
## Report bugs in GitHub issues or Discord
|
||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
||||
|
||||
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
|
||||
## Build a plugin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Feature requests opened as issues will get closed automatically
|
||||
Most specialized workflows and integrations are better served by plugins.
|
||||
|
||||
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
|
||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
||||
|
||||
You may submit a PR to fix a bug, please read the PR guidelines below.
|
||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
||||
|
||||
## Product discussions go in GitHub Discussions or Discord
|
||||
## Report bugs
|
||||
|
||||
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
|
||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
||||
|
||||
Include:
|
||||
|
||||
- What you did, what you expected, and what happened.
|
||||
- Reproduction steps, Paseo version, and platform.
|
||||
- Relevant logs, screenshots, or a recording.
|
||||
|
||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
||||
|
||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
||||
|
||||
## Discuss workflows before proposing features
|
||||
|
||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
||||
|
||||
Explain:
|
||||
|
||||
- What are you trying to do?
|
||||
- How do you do it today?
|
||||
- Where does Paseo get in the way?
|
||||
- What would the flow look like if it worked for you?
|
||||
- What would a better workflow look like?
|
||||
|
||||
"Can you build X" is not useful because it doesn't tell me about your problem.
|
||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
||||
|
||||
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
|
||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
||||
|
||||
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
|
||||
## If you still want to submit a pull request
|
||||
|
||||
## Pull requests
|
||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
||||
|
||||
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
|
||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
||||
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
||||
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
||||
|
||||
Here is the criteria I use to decide:
|
||||
If you choose to submit a PR, these are the basics to cover.
|
||||
|
||||
✅ Likely to be accepted
|
||||
### What I look for in a core feature
|
||||
|
||||
- Fits the product vision
|
||||
- One focused change
|
||||
- Links the bug it fixes, or the discussion behind it if there is one
|
||||
- Addresses bot reviews
|
||||
- Explains the problem you're solving
|
||||
- QA evidence
|
||||
- Automated tests
|
||||
- Screenshots or video for UI changes, on every affected platform
|
||||
- Says which platforms you tested and which you didn't
|
||||
- Maintainer edits enabled
|
||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
||||
|
||||
⛔️ Will be rejected
|
||||
### Keep the scope small
|
||||
|
||||
- Bundles unrelated changes
|
||||
- Fails checks
|
||||
- Ignores bot reviews
|
||||
- Takes a feature or the design in a direction I don't want
|
||||
- No QA evidence
|
||||
- No tests
|
||||
- Clearly fully AI-generated PR
|
||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
||||
|
||||
### What to expect
|
||||
### QA evidence
|
||||
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
||||
|
||||
## QA evidence
|
||||
Explain the problem and link the relevant bug report or discussion. Include:
|
||||
|
||||
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
|
||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
||||
- The commands you ran and their output.
|
||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
||||
- The platforms you tested and any affected platforms you could not test.
|
||||
|
||||
Pull requests without evidence will be closed.
|
||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
||||
|
||||
What's accepted:
|
||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
||||
|
||||
- The shell commands you ran, pasted with their output
|
||||
- The tests you added and their results
|
||||
- Before and after screenshots
|
||||
- A video of the whole interaction
|
||||
- Logs, requests, responses
|
||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
||||
|
||||
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
|
||||
### What happens to your contribution
|
||||
|
||||
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
|
||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
||||
|
||||
## I want to become a maintainer
|
||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
||||
|
||||
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
|
||||
## Help the community
|
||||
|
||||
## FAQ
|
||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
||||
|
||||
### I'm blocked, where do I go?
|
||||
|
||||
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
|
||||
|
||||
### Do you prefer Github or Discord?
|
||||
|
||||
I am more active on Discord for general questions.
|
||||
|
||||
I prefer Github for bugs because the template helps collect information more efficiently.
|
||||
|
||||
### Why was my feature request closed?
|
||||
|
||||
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
|
||||
|
||||
### Why didn't my PR get a detailed review?
|
||||
|
||||
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
|
||||
|
||||
### How long until someone looks at my pull request?
|
||||
|
||||
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
|
||||
|
||||
### Why do you want tests and a video for a small change?
|
||||
|
||||
Because it's faster and safer than me checking out and running every PR myself to test it manually.
|
||||
|
||||
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
|
||||
|
||||
### I only have one platform, can I still contribute?
|
||||
|
||||
Yes. Test what you have, and say what you didn't test.
|
||||
|
||||
### Can I use an agent to write my contribution?
|
||||
|
||||
Yes, as long as you understand what you're submitting and you tested it yourself.
|
||||
|
||||
### Is there a roadmap I can read?
|
||||
|
||||
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
|
||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -50,10 +50,9 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
|
||||
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
|
||||
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
@@ -194,6 +193,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
+6
-2
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
||||
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
||||
|
||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
@@ -81,7 +81,7 @@ const client = new DaemonClient({
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
// ... test ...
|
||||
|
||||
|
||||
+33
-10
@@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
|
||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
|
||||
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
|
||||
resume repairs that drift through the provider's native unarchive hook; history resume does not.
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration. Other archived agents stay archived.
|
||||
|
||||
Opening an agent is a navigation choice, independent of whether its details are cached. The
|
||||
layout retains that choice across reload while the panel fetches the agent from the daemon.
|
||||
Once the daemon reports the agent active, its tab follows normal archive propagation again.
|
||||
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
|
||||
not own selection or release the explicit open.
|
||||
|
||||
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
|
||||
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||
@@ -148,6 +160,17 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
```
|
||||
|
||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
||||
@@ -28,6 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
@@ -120,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
## F-Droid / source-only Android builds
|
||||
|
||||
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
|
||||
|
||||
+62
-7
@@ -54,6 +54,15 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
Desktop and CLI import server capabilities through explicit package subpaths such as
|
||||
`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export
|
||||
loads daemon bootstrap eagerly, even when a caller only uses a path or process helper.
|
||||
Keep process-management and configuration dependencies independent of bootstrap and
|
||||
WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules;
|
||||
`messages` imports and re-exports them. This prevents every supervising process from
|
||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
||||
these dependency trees with tree-shaking disabled to match unbundled production imports.
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
@@ -96,7 +105,8 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
@@ -144,7 +154,7 @@ traffic. Workspace assignments stay on the workspace directory sequence.
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
@@ -238,11 +248,22 @@ Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
There is no dedicated welcome message; the server emits one initial `status.server_info` after
|
||||
accepting hello. Modern clients negotiate `owned_subscriptions` against
|
||||
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
|
||||
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
|
||||
correlates the initial reply by request ID, then routes updates by subscription ID.
|
||||
|
||||
A logical Session can contain several physical sockets. Each socket owns its capabilities,
|
||||
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
|
||||
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
|
||||
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
|
||||
its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
@@ -296,7 +317,7 @@ messages or transfers.
|
||||
|
||||
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
||||
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
||||
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
|
||||
|
||||
Example: adding a new enum value
|
||||
|
||||
@@ -307,6 +328,36 @@ Example: adding a new enum value
|
||||
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
||||
```
|
||||
|
||||
### Creation ownership
|
||||
|
||||
Creation is owned by the daemon across socket lifetimes. A workspace request can include
|
||||
its initial agent and prompt. Workspace readiness is published before provider startup;
|
||||
the app keeps its existing disk-ready navigation and observes the remaining creation.
|
||||
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
|
||||
identities, not ready workspace records.
|
||||
|
||||
The creation journal (`server/creation/`) owns identity and execution for both legacy and
|
||||
modern creation RPCs. Requesting progress never selects a different journal. Existing
|
||||
agent receipts are imported at this boundary; message delivery receipts remain separate.
|
||||
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
|
||||
key. Reconnect subscribes to that key; retries
|
||||
join active work or reuse committed stages. A persisted resource alone cannot prove
|
||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
||||
receipt return an unknown outcome instead of being repeated.
|
||||
|
||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
||||
sequence after the client process disappears. Explicitly requested IDs or receipts
|
||||
that an old host cannot honor produce an unsupported error. Keep these adapters inside
|
||||
the client package, as an exception to the default no-fallback feature policy.
|
||||
|
||||
Creation executes through the existing Session capabilities. Connection-owned delivery
|
||||
controls observation only: detaching a socket or cleaning up its Session does not cancel
|
||||
accepted creation. Updates require an explicit subscription and go only to that socket;
|
||||
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
|
||||
existing response contract.
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
@@ -417,12 +468,16 @@ $PASEO_HOME/
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
├── paseo.pid # Supervisor identity and published bound endpoint
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
|
||||
|
||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
|
||||
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see:
|
||||
by retrying until the frame appears;
|
||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
||||
the permanent production parking state;
|
||||
- guest background throttling can be disabled once at attach without per-capture
|
||||
renderer coordination;
|
||||
- parked guests remain capturable with Chromium background throttling enabled;
|
||||
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
|
||||
host composer;
|
||||
- the automation group loads the compiled production keyboard boundary and guest
|
||||
@@ -73,11 +72,17 @@ The harness writes PNG evidence and `results.json` to:
|
||||
packages/desktop/capture-harness/out/
|
||||
```
|
||||
|
||||
A passing run prints `PASS` lines for the production P1 attach-off parking state,
|
||||
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
|
||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
||||
is usually saved as 2560x1600.
|
||||
|
||||
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
|
||||
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
|
||||
and resumes animation after restoring the window. Its artifacts include the screenshot
|
||||
and animation measurements. Full-page content correctness remains separately tracked in
|
||||
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
|
||||
|
||||
## Mechanism
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
@@ -96,8 +101,11 @@ plane stays below the overlay plane regardless of body insertion order; menus ke
|
||||
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
|
||||
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
invalidates before each attempt, and retries known first-frame failures within the
|
||||
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
There is no renderer prep/restore handshake or lifetime background-throttling override.
|
||||
Screenshot capture temporarily enables frame production inside the shared serialized queue,
|
||||
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
|
||||
The browser tool takes one viewport frame through Electron's frame subscription and releases the
|
||||
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
|
||||
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
|
||||
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
|
||||
metrics and screenshot clip.
|
||||
|
||||
@@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
|
||||
@@ -714,7 +714,7 @@ Each entry in the `models` array:
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
|
||||
+12
-8
@@ -48,6 +48,7 @@ $PASEO_HOME/
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── local-credential # Per-run local client credential (mode 0600)
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {sanitized-cwd}/
|
||||
@@ -64,8 +65,8 @@ $PASEO_HOME/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
├── plugins/
|
||||
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||
│ ├── sources.json # Managed kind and Git acquisition remote
|
||||
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -254,11 +255,13 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
|
||||
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
|
||||
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
|
||||
Paseo writes it atomically. An update creates and validates a new version directory before changing
|
||||
the configured directory path; successful activation removes the old version.
|
||||
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
|
||||
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
|
||||
remote. Installed revision, package/subdirectory identity and ownership root come from retained
|
||||
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
|
||||
explains their authority. An update prepares a new directory before replacing the active path and
|
||||
removing the previous version. Old record fields are accepted at the store boundary and ignored;
|
||||
there is no startup migration or persistent update policy.
|
||||
|
||||
### Profile lists
|
||||
|
||||
@@ -488,7 +491,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
@@ -555,6 +558,7 @@ These small files are not validated as full Zod schemas but are persisted under
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
|
||||
+5
-4
@@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values
|
||||
|
||||
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
||||
|
||||
Enable it for a running daemon with:
|
||||
Enable it in persistent configuration:
|
||||
|
||||
```bash
|
||||
paseo daemon start --web-ui
|
||||
paseo daemon config set features.webUi.enabled true
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon run
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
@@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
`--host` overrides the environment variable.
|
||||
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ the web UI. The served app receives a same-origin connection hint and connects
|
||||
back to that daemon. Static UI files load without daemon auth; API and
|
||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||
|
||||
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -30,7 +30,10 @@ never read or modify that width.
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Callers request the content and never choose the shell.
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
even for a different PR; moving or reordering it remains the user's choice.
|
||||
Callers request the content and never choose the shell.
|
||||
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
||||
routes later presses to the working diff through the shared diff preference.
|
||||
|
||||
@@ -45,7 +48,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
|
||||
focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
@@ -57,8 +60,11 @@ lifecycle.
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
|
||||
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
|
||||
tab content. There is no hidden side-pane lifecycle.
|
||||
|
||||
Placement intent still controls existing tabs:
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ state.
|
||||
This split is deliberate. The host layout must mount first so native local
|
||||
dynamic params exist before any nested workspace leaf is selected.
|
||||
|
||||
## Error Recovery
|
||||
|
||||
The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
|
||||
remounts the router at `/open-project`, preserving saved workspace layouts and
|
||||
the remembered selection. Do not move the boundary inside the root layout:
|
||||
catching a render error there unmounts the navigator, so the recovery button
|
||||
cannot reliably dispatch a navigation reset. Starting recovery at `/` would
|
||||
restore the same workspace and could repeat the crash.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+59
-5
@@ -58,7 +58,10 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
hit-testing starts fresh in that window. If its children use gesture-handler,
|
||||
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
|
||||
the app's root does not span Android windows. React context alone does not
|
||||
establish a native gesture root. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
@@ -134,13 +137,38 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
|
||||
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
|
||||
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
|
||||
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
|
||||
The optional third child is an overlay that moves with the surface. New workspace
|
||||
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
|
||||
space or translate the composer themselves. Put the dismiss surface behind content
|
||||
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
|
||||
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
|
||||
MOVE events before its native scroll view reaches touch slop. Streaming can hide
|
||||
this failure by delaying the responder grant. Native scroll views retain their own
|
||||
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
|
||||
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
|
||||
need `none`); otherwise their empty padding blocks hit testing of that sibling.
|
||||
The shared screen header uses the same dismiss surface. Keep the
|
||||
header escape available when a capped draft clips all content above it, since
|
||||
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
|
||||
and the stream keeps its existing flick-to-dismiss gesture.
|
||||
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
||||
signal because the stock iOS Reanimated value changes at move start, while
|
||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
|
||||
|
||||
`KeyboardDock` always keeps the chat surface at full height and wraps it in
|
||||
`ComposerDock` always keeps the surface at full height and wraps it in
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
@@ -154,7 +182,33 @@ layout. Preserve the translated dock's history scroll range with a far-end
|
||||
content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
The translated dock's height is not the composer's available height. Bound the
|
||||
composer against the stationary space below the header inside
|
||||
`composer/dock`, including its controls and attachments. Reserve the
|
||||
keyboard destination at move start on the UI thread. Waiting for a settled JS
|
||||
update lets a long draft disappear behind the header during opening. This is a
|
||||
boundary-time layout change, not an animated layout prop. Keep the input's
|
||||
native intrinsic sizing and internal scrolling. Keep the editing input's
|
||||
`defaultValue` current on every edit: Fabric re-measures the Android input from
|
||||
the JS text whenever its frame changed, so a stale prop inflates or collapses
|
||||
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
|
||||
|
||||
Closing the keyboard moves the composer; it does not enlarge its capacity. The
|
||||
viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
Yoga split the shortage between them: rows vanished behind the composer while
|
||||
the input lost lines at the same time. Keep that separation inside the dock,
|
||||
shared by every phone host. The centered tablet form caps the complete block
|
||||
for header clearance, but reserves the composer's own capped height before
|
||||
shrinking the setup scroll view. The form must not take editing space from the
|
||||
composer.
|
||||
|
||||
Move the stream and composer together through `ComposerDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
|
||||
+4
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
@@ -31,6 +31,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+10
-3
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
@@ -49,6 +52,10 @@ Provider controls remain provider-native; see [providers.md](providers.md).
|
||||
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
|
||||
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
|
||||
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
|
||||
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
|
||||
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
|
||||
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
|
||||
for the lifetime of a draft and reuses the first message ID after failures.
|
||||
|
||||
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
|
||||
@@ -179,6 +179,10 @@ its own.
|
||||
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
||||
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
||||
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
||||
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
@@ -187,6 +191,10 @@ its own.
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
|
||||
@@ -116,6 +116,8 @@ definition, no longer eligible to begin.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM
|
||||
nodes carry no Fabric ownership; the native file keeps the chat live.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -47,6 +47,33 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
|
||||
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
|
||||
to test an installed production build. It never submits a chat message.
|
||||
|
||||
`npm run test:e2e:composer-keyboard:android` preserves the chat control and
|
||||
keyboard regression flow, then runs the same growth, retained-height, bounds, and
|
||||
hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
does not prove that native scrolling receives touches.
|
||||
Artifacts are grouped by host under
|
||||
`.dev/agent-device-artifacts/composer-keyboard-android`. Configure its daemon,
|
||||
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
```bash
|
||||
adb shell pm disable-user --user 0 com.google.android.gm
|
||||
adb shell pm disable-user --user 0 com.google.android.calendar
|
||||
```
|
||||
|
||||
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
|
||||
|
||||
## Maestro compatibility
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+93
-59
@@ -1,10 +1,10 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
|
||||
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
|
||||
Changing that root field is
|
||||
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
|
||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
@@ -68,7 +69,7 @@ runtime modules, so consumers do not install these packages when adding the plug
|
||||
```
|
||||
|
||||
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
|
||||
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
|
||||
[requirements contract](../public-docs/plugins/reference.md#requirements), including legacy
|
||||
manifests and prerelease matching.
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
@@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
|
||||
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
|
||||
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
|
||||
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
|
||||
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
|
||||
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
@@ -103,10 +113,36 @@ paseo plugin update --all
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
|
||||
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
|
||||
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
|
||||
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
|
||||
npm package.json checked against package-lock.json provide the current revision. Keep the complete
|
||||
npm dependency tree and lockfile after staging activation. No install selector governs updates.
|
||||
|
||||
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
|
||||
disabled during acquisition; manifest preparation owns required commands before validation.
|
||||
|
||||
List responses retain the closed `source: directory | git` enum. The optional `installation`
|
||||
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
|
||||
emitted. Installation uses `features.pluginSourceInstallation`; reviewed preview/apply uses
|
||||
`features.pluginSourceUpdates`, gated by the SDK and CLI entry. The old immediate-update request
|
||||
returns update-client guidance. Basic management remains independently available.
|
||||
|
||||
Preview returns exact targets and expected installed identity/root/revision. Apply validates those
|
||||
values, acquires the displayed artifact or commit, then rechecks state before activation. No review
|
||||
session is stored in the daemon. PluginService owns activation/recovery and independent bulk results.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
@@ -162,10 +198,10 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
|
||||
[host UI contract](../public-docs/plugins/reference.md#host-ui).
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
### SDK import boundaries
|
||||
|
||||
@@ -206,7 +242,7 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
|
||||
browser globals are not available across the plugin. Put sanctioned web-only APIs in
|
||||
`client/web.ts`, declare only the globals that module uses, gate each export with
|
||||
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
|
||||
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
|
||||
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
@@ -253,6 +289,11 @@ selected host's existing connection; switching the screen's host changes both `u
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
|
||||
@@ -261,7 +302,7 @@ catalog is complete.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
@@ -278,15 +319,16 @@ when moved between hosts. Explorer configuration can create workspace-context pa
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
|
||||
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
|
||||
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
|
||||
@@ -334,7 +376,7 @@ and `plugin-examples/provider-acp-transformer`.
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
daemon-appended plugin items. The direct example includes both sides. The renderer-only
|
||||
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
@@ -344,55 +386,43 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
## Usage sources
|
||||
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
## 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)
|
||||
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.
|
||||
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
|
||||
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
built-in projection stay unchanged. The app transforms each source item while building the render
|
||||
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
|
||||
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
|
||||
model, for both fetched history and live events, before native Markdown splitting and Overview
|
||||
tool grouping. Assistant callbacks receive the accumulated source text, never display fragments.
|
||||
Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`.
|
||||
Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so
|
||||
streaming updates preserve mounted component identity.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
@@ -489,7 +519,7 @@ drops the optional presentation fields.
|
||||
|
||||
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
|
||||
The host settings shell owns navigation and layout; plugin content must not add another page
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/reference.md#settings-screens)
|
||||
and `plugin-examples/settings` for the named UI components and persistence API.
|
||||
|
||||
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
|
||||
@@ -497,6 +527,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m
|
||||
so updates and reloads retain values. Settings-change notifications must not enter the catalog
|
||||
reload path: that path disposes the plugin and would destroy open drafts after every save.
|
||||
|
||||
`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current
|
||||
`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The
|
||||
subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry.
|
||||
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
@@ -520,7 +554,7 @@ stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/migration.md).
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||
|
||||
+74
-53
@@ -1,81 +1,102 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
|
||||
|
||||
## What is Paseo
|
||||
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
## Agents are the focus
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
|
||||
|
||||
## Core philosophy
|
||||
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
## A low floor and a high ceiling
|
||||
|
||||
## How it works
|
||||
### Easy to start
|
||||
|
||||
### Projects and workspaces
|
||||
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
|
||||
|
||||
### Inside a workspace
|
||||
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
### Room to build your own setup
|
||||
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
|
||||
|
||||
### The daemon
|
||||
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
|
||||
|
||||
This architecture means:
|
||||
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
|
||||
|
||||
## Target user
|
||||
## Freedom, ownership, and privacy
|
||||
|
||||
Anyone who builds software:
|
||||
Paseo's flexibility depends on keeping control with the user:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
|
||||
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
|
||||
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
|
||||
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
|
||||
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
|
||||
|
||||
## What compounds over time
|
||||
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
## A lean core that keeps improving
|
||||
|
||||
## Strategic bets
|
||||
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
|
||||
|
||||
## Current state (May 2026)
|
||||
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
|
||||
|
||||
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
|
||||
|
||||
## Extensibility and composition
|
||||
|
||||
Plugins are a central part of how Paseo gains capability.
|
||||
|
||||
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
|
||||
|
||||
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
|
||||
|
||||
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
|
||||
|
||||
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
|
||||
|
||||
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
|
||||
|
||||
## How Paseo develops
|
||||
|
||||
Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
|
||||
|
||||
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
|
||||
|
||||
- Can people discover and understand it?
|
||||
- Does it work well in their actual workflows?
|
||||
- What friction or missing capability appears with repeated use?
|
||||
- Does the next addition improve the shared experience or belong in a plugin?
|
||||
|
||||
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
|
||||
|
||||
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
|
||||
|
||||
## An ecosystem people can build on
|
||||
|
||||
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
|
||||
|
||||
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
|
||||
|
||||
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
|
||||
|
||||
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
|
||||
|
||||
@@ -10,6 +10,8 @@ Two contracts follow from it.
|
||||
|
||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||
|
||||
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
||||
|
||||
- New fields are `.optional()` with a sensible default.
|
||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||
@@ -38,6 +40,40 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
The client package advertises the protocol behavior it implements. Add each new capability to
|
||||
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
||||
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
||||
automation, or explicit overrides. A schema accepting a message does not establish support for
|
||||
its delivery semantics.
|
||||
|
||||
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
|
||||
behavior once at its connection boundary. App workflows use the same observation interface on both.
|
||||
|
||||
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
|
||||
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
|
||||
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
|
||||
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
|
||||
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
|
||||
or multiplexing emulation are introduced.
|
||||
|
||||
Preserve established workflows when changing delivery internals. Independent filters and quiet
|
||||
connections require a capable daemon; opening the app, reading history and using terminals do not.
|
||||
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
|
||||
|
||||
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
|
||||
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
|
||||
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
||||
for parsing compatibility; modern requests cannot select their subscription ID.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
+36
-23
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
@@ -49,11 +58,18 @@ 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.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -63,6 +79,8 @@ A provider that can register runtime tools directly should set `supportsNativePa
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
@@ -79,9 +97,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
@@ -173,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
## Usage sources
|
||||
|
||||
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
|
||||
|
||||
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
- `providerId`, `displayName`, `status`, and optional `planLabel`
|
||||
- any number of `windows` such as Session, Weekly, or Biweekly
|
||||
- optional `balances` for credits, USD, requests, or tokens
|
||||
- optional `details` for provider-specific rows
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
### Credentials are read only
|
||||
|
||||
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
|
||||
|
||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||
|
||||
### Usage fetchers are read-only on credentials
|
||||
|
||||
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+34
-1
@@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## Release branch discipline
|
||||
|
||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
||||
release. This applies to both beta and stable releases.
|
||||
|
||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
||||
PR, and keep that destination through delivery.
|
||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
||||
Pass CI and merge without squashing away the individual PR commits needed for
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
### Hotfix from a release tag
|
||||
|
||||
If `main` contains changes you do not want to release, branch from the affected
|
||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
||||
use the normal release flow with it as the explicit source, choosing a new patch
|
||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
||||
`next`, preserving newer development and version changes there. This is a
|
||||
short-lived hotfix branch, not another maintained release track.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
@@ -64,7 +94,7 @@ release push as the changelog and version commit.
|
||||
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
@@ -162,6 +192,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
|
||||
@@ -486,6 +517,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
|
||||
@@ -26,6 +26,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
||||
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
|
||||
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
|
||||
|
||||
|
||||
+8
-4
@@ -107,14 +107,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
|
||||
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
|
||||
|
||||
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||
|
||||
- the `paseo://app/` renderer mounts into `#root`;
|
||||
- the sandboxed preload exposes the desktop bridge;
|
||||
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||
- the bundled CLI can query that daemon and run a terminal command.
|
||||
|
||||
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
|
||||
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
|
||||
|
||||
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
|
||||
|
||||
Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence.
|
||||
|
||||
To exercise the smoke locally on Linux:
|
||||
|
||||
@@ -128,7 +132,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
|
||||
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
|
||||
|
||||
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
|
||||
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
|
||||
|
||||
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
|
||||
|
||||
@@ -200,7 +204,7 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths.
|
||||
|
||||
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
|
||||
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
+64
-14
@@ -5,8 +5,10 @@ Agent chat delivery has two paths:
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||
|
||||
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||
authority and repopulates those rows when an agent resumes.
|
||||
The daemon retains projected items in memory. Each source event advances the stream sequence,
|
||||
then replaces the previous tool state or merges into the current text item. Intermediate payloads
|
||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||
and rebuilds the projection when an agent resumes.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -67,15 +69,34 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
||||
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
||||
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
|
||||
page.
|
||||
Focus alone does not mutate timeline state; the tail response is compared with the local
|
||||
Opening, reconnecting, and returning from app background establish the current timeline through
|
||||
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
|
||||
Focus alone does not mutate timeline state; the response is compared with the local
|
||||
authoritative range first.
|
||||
|
||||
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
|
||||
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
|
||||
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
|
||||
one; disconnect and backgrounding keep it.
|
||||
|
||||
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
|
||||
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
|
||||
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
|
||||
|
||||
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
|
||||
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
|
||||
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
|
||||
@@ -88,6 +109,25 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
|
||||
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
|
||||
history start; a response requested from a pre-replacement range is stale and is discarded.
|
||||
|
||||
A plan approval keeps the original proposal's tool-call identity through resolution and provider
|
||||
history replay. The pending approval UI can hide that tool from presentation, but the client model
|
||||
must retain its position. Creating a new history card on rejection places it after the prompt that
|
||||
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
||||
|
||||
## Provider child history
|
||||
|
||||
Child transcripts use the same projected-page reconciliation as the main conversation. The client
|
||||
retains rendered items and sequence cursors, never a second cache of source events. Pagination
|
||||
uses the projected display anchor; live updates advance the source cursor without moving tools.
|
||||
|
||||
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
|
||||
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
|
||||
an update-host notice for that pane.
|
||||
Older clients retain child names/status and receive an upgrade message when opening a child
|
||||
conversation. This gate affects only the child transcript; it does not gate the connection,
|
||||
main conversation, or current-context forks. A legacy `canonical` root request still receives
|
||||
projected items.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
@@ -95,6 +135,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
|
||||
behind one interface. React supplies transport and projection operations without selecting a cache
|
||||
path or issuing a separate persistence notification.
|
||||
|
||||
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
|
||||
history response can arrive before the active-list response that omits it. Clearing history there
|
||||
would discard accepted rows while the viewed owner still considers them synchronized. Entity
|
||||
deletion clears the transcript; list membership changes do not.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
@@ -128,14 +173,19 @@ replica cache.
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
|
||||
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
|
||||
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
|
||||
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
|
||||
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
|
||||
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
|
||||
an evicted retained timeline displays its cached state immediately while authoritative catch-up
|
||||
advances it to the current tail.
|
||||
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
|
||||
enters that set the first time the user opens it and leaves when its tab closes or the session
|
||||
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
|
||||
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
|
||||
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
|
||||
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
|
||||
subscribing from layout spawns a provider session per historical tab at every launch and stamps
|
||||
every one of those workspaces as just used (see
|
||||
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
|
||||
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
|
||||
does not starve background recovery. Split panes catch up together. Hidden chats update the
|
||||
replica; on web their retained presentation stays suspended until revealed, on native it keeps
|
||||
rendering. Revealing a chat reads the current store and preserves its local UI state.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,5 @@
|
||||
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
|
||||
|
||||
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
"plugins": {
|
||||
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
|
||||
"project": ["**/*.{ts,tsx}"]
|
||||
},
|
||||
".": {
|
||||
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
|
||||
Generated
+303
-27
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -6333,6 +6334,10 @@
|
||||
"resolved": "packages/app",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/builtin-plugins": {
|
||||
"resolved": "plugins",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/cli": {
|
||||
"resolved": "packages/cli",
|
||||
"link": true
|
||||
@@ -8271,6 +8276,84 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -8644,6 +8727,48 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
|
||||
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.10",
|
||||
"@opencode/schema": "2.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
|
||||
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.10",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
|
||||
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -18622,6 +18747,16 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-rc.112",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
|
||||
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-check": "^4.9.0",
|
||||
"msgpackr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
@@ -21866,6 +22001,28 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -28141,6 +28298,37 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
@@ -28466,6 +28654,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -30141,6 +30344,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -36196,7 +36415,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36268,6 +36487,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
@@ -37261,12 +37481,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/server": "0.10.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37513,10 +37733,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37527,7 +37747,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37538,6 +37758,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
@@ -37770,7 +37991,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38666,7 +38887,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38695,14 +38916,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38712,8 +38933,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38735,7 +38956,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38762,7 +38983,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38980,19 +39201,20 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/highlight": "0.10.0",
|
||||
"@getpaseo/plugin": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39002,6 +39224,7 @@
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lru-cache": "^11.5.1",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"openai": "^6.44.0",
|
||||
@@ -39012,6 +39235,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"semver": "^7.8.5",
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -39019,11 +39243,13 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
@@ -39468,6 +39694,17 @@
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@types/markdown-it": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
@@ -39706,6 +39943,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
@@ -39853,6 +40102,21 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
@@ -39867,7 +40131,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41469,6 +41733,18 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -21,6 +21,7 @@
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -66,7 +67,7 @@
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
|
||||
@@ -5,6 +5,7 @@ const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storag
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -141,6 +142,7 @@ export default {
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
|
||||
@@ -36,14 +36,18 @@ import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
recordPanelToasts,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openSessions,
|
||||
clickSessionRow,
|
||||
expectWorkspaceTabVisible,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -428,6 +432,18 @@ async function expectInterruptedTurnOrderAfterReconnect(
|
||||
}
|
||||
}
|
||||
|
||||
async function visitEvictionWorkspaces(
|
||||
page: Page,
|
||||
agents: readonly { agentId: string }[],
|
||||
): Promise<void> {
|
||||
for (const [index, agent] of agents.entries()) {
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, `Workspace eviction ${index + 1}.`);
|
||||
await expectWorkspaceTabVisible(page, agent.agentId);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
@@ -443,50 +459,76 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
|
||||
title: `Workspace eviction ${index + 1}`,
|
||||
title: `Workspace eviction ${index + 1}.`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const prompt = "Keep this hidden image prompt before its streaming output.";
|
||||
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
|
||||
const openAgentIds = [target.agentId, ...evictionAgents.map((agent) => agent.agentId)];
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
// Open every chat before holding output. Adding chats replaces the timeline
|
||||
// subscription and retires the IDs stamped on already-buffered frames.
|
||||
await visitEvictionWorkspaces(page, evictionAgents);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, target.agentId);
|
||||
await expectComposerVisible(page);
|
||||
const subscriptionRequests = gate.getClientRequestCount(
|
||||
"agent.timeline.set_subscription.request",
|
||||
);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
gate.setAgentStreamItemSuppressed("user_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents([
|
||||
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
|
||||
]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await gate.waitForHeldAgentStreamEvent("turn_started");
|
||||
// Finish production before navigation so passing cannot depend on late
|
||||
// chunks arriving after the final workspace switch.
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
|
||||
// Navigate inside the app: a document reload discards the retained deck and
|
||||
// adds startup history fetches, so it cannot prove eviction/resume behavior.
|
||||
await visitEvictionWorkspaces(page, evictionAgents);
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
|
||||
subscriptionRequests,
|
||||
);
|
||||
gate.releaseHeldAgentStreamEvent("turn_started");
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
await subscriptions.waitForSubscribedAgents(openAgentIds);
|
||||
|
||||
const responseStart = page.getByText("Cycle 1", { exact: true });
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(responseStart).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, responseStart);
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
// Open chats stay subscribed when their workspace view is evicted. Returning
|
||||
// uses that live timeline without another resume check or startup tail fetch.
|
||||
expect(rememberTimelineRequestCounts(gate, target.agentId)).toEqual(requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.setAgentStreamItemSuppressed("user_message", false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
@@ -868,7 +910,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one canonical prompt when the provider echoes before accepting", async ({
|
||||
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -889,6 +931,20 @@ test.describe("Agent message submission", () => {
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
for (const [index, nextPrompt] of [
|
||||
"emit 1 coalesced agent stream updates for the second turn.",
|
||||
"emit 1 coalesced agent stream updates for the third turn.",
|
||||
].entries()) {
|
||||
await submitMessage(page, nextPrompt);
|
||||
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
|
||||
}
|
||||
await fillComposerDraft(page, "Keep this unsent draft.");
|
||||
await composerLocator(page).blur();
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(3);
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -908,16 +964,12 @@ test.describe("Agent message submission", () => {
|
||||
await submitMessage(page, prompt);
|
||||
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
const marker = `late-provider-identity-${Date.now()}`;
|
||||
await submittedPrompt.evaluate((element, value) => {
|
||||
element.dataset.lateProviderIdentity = value;
|
||||
}, marker);
|
||||
|
||||
await submittedPrompt.hover();
|
||||
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
} finally {
|
||||
@@ -1114,8 +1166,11 @@ test.describe("Agent message submission", () => {
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-relative-time-",
|
||||
title: "Relative time agent",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
|
||||
await resetSeededPageState(page);
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await openSessions(page);
|
||||
|
||||
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -88,18 +88,12 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const evidence = { addedNodes: 0, characterDataMutations: 0, removedNodes: 0 };
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
if (record.type === "characterData") evidence.characterDataMutations += 1;
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
withSpokenTimeline,
|
||||
captureSpokenTimeline,
|
||||
reloadSpokenTimeline,
|
||||
expectSpokenTimelinePrompt,
|
||||
sendSpokenTimelinePrompt,
|
||||
expectStableHistoryStartGutter,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
@@ -12,7 +16,6 @@ import {
|
||||
holdOlderHistoryPages,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
@@ -26,42 +29,28 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
test("shows spoken words without voice instructions in history, live updates and after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const historyText = "Please check the voice history.";
|
||||
const liveText = "Now check the live voice message.";
|
||||
await withSpokenTimeline(historyText, async (agent) => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
await expectSpokenTimelinePrompt(page, historyText);
|
||||
await sendSpokenTimelinePrompt(agent, liveText);
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
await captureSpokenTimeline(page, testInfo, "desktop");
|
||||
await reloadSpokenTimeline(page);
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
await captureSpokenTimeline(page, testInfo, "compact");
|
||||
await expectSpokenTimelinePrompt(page, liveText);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
test("keeps the history-start gutter and visible position stable through the final page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
@@ -73,8 +62,10 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
} finally {
|
||||
@@ -96,7 +87,9 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
@@ -137,23 +130,6 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps visible history anchored when live output grows during a prepend", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -181,7 +157,8 @@ test.describe("Agent timeline pagination", () => {
|
||||
|
||||
test("finishes loading an older page while live output continues", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
// The live turn streams for thirty minutes, so it is still running at every assertion.
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
@@ -193,8 +170,11 @@ test.describe("Agent timeline pagination", () => {
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
);
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText("walking through").first()).toBeAttached();
|
||||
history.releasePage(1);
|
||||
|
||||
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
|
||||
const running = await agent.client.fetchAgents({ scope: "active" });
|
||||
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
|
||||
@@ -205,28 +185,7 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues one loading operation while older pages still leave history start exposed", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
@@ -235,12 +194,12 @@ test.describe("Agent timeline pagination", () => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const loading = await rememberOlderHistoryLoadingOperation(page);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelineAtHistoryStart(page);
|
||||
await expectSameOlderHistoryLoadingOperation(page, loading);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await forkInFlightTurnToNewTab(page);
|
||||
await expectChatHistoryAttachment(page);
|
||||
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
|
||||
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: sourceAgentTitle }).click();
|
||||
await expectLiveAssistantText(page, visibleAfterFork);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import type { BrowserContext, Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
}
|
||||
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
for (const [tag, text] of [
|
||||
["strong", "strong prose"],
|
||||
["em", "emphasized prose"],
|
||||
|
||||
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
|
||||
await openChangedFilesOverview(page);
|
||||
await test.step("reopening the overview expands folders again", async () => {
|
||||
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
|
||||
});
|
||||
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
|
||||
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
|
||||
});
|
||||
});
|
||||
|
||||
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
|
||||
const committed = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, committed, 90_000);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, committed);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -954,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
test("changes diff waits for configured fonts, then applies code typography changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await holdBrowserFontLoads(page);
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
|
||||
await test.step("commit no geometry before the configured fonts are ready", async () => {
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
const before = await readDiffTypographyGeometry(page);
|
||||
|
||||
await changeCodeTypographyFromSettings(page, {
|
||||
@@ -977,63 +1013,31 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await holdBrowserFontLoads(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
|
||||
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await cancelInlineReview(page);
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect
|
||||
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
@@ -1143,36 +1147,36 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||
context,
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-selection").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-line").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
|
||||
await test.step("copy the selected characters with the keyboard", async () => {
|
||||
await copyDiffSelectionWithKeyboard(page);
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
await test.step("copy the preserved selection after resizing", async () => {
|
||||
await resizeDiffViewportHeight(page, 960);
|
||||
await copyFromChangedLineMenu(page, "Copy");
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
});
|
||||
await test.step("copy the complete source line", async () => {
|
||||
await copyFromChangedLineMenu(page, "Copy line");
|
||||
await expectClipboardText(page, "ABCDEFGHIJ");
|
||||
});
|
||||
await test.step("dismiss a new selection without starting a review", async () => {
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
await rightClickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
@@ -1186,19 +1190,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||
context,
|
||||
page,
|
||||
@@ -1393,14 +1384,17 @@ async function setOpenChangesPresentation(
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds every font load in the document until released; later loads pass through. */
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
const originalLoad = fontSet.load.bind(fontSet);
|
||||
const pending: Array<() => void> = [];
|
||||
let released = false;
|
||||
Object.defineProperty(fontSet, "load", {
|
||||
configurable: true,
|
||||
value(font: string, text?: string) {
|
||||
if (released) return originalLoad(font, text);
|
||||
return new Promise<FontFace[]>((resolve, reject) => {
|
||||
pending.push(() => {
|
||||
originalLoad(font, text).then(resolve, reject);
|
||||
@@ -1410,6 +1404,7 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
Object.assign(window, {
|
||||
__releasePaseoDiffFontLoads() {
|
||||
released = true;
|
||||
for (const release of pending.splice(0)) release();
|
||||
},
|
||||
});
|
||||
@@ -1659,12 +1654,72 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
|
||||
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
// Reload at the compact size: panel selection starts at the center on a cold
|
||||
// mount, independently of any desktop sidebar used earlier in the test.
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
async function openChangedFilesOverview(page: Page): Promise<void> {
|
||||
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
|
||||
await expect(jumpToFile).toBeVisible();
|
||||
await jumpToFile.click();
|
||||
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
|
||||
}
|
||||
|
||||
async function expectOverviewReopensExpanded(
|
||||
page: Page,
|
||||
folderName: string,
|
||||
childFileName: string,
|
||||
): Promise<void> {
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
const tree = changesTree(page);
|
||||
const folder = tree
|
||||
.getByRole("button")
|
||||
.filter({ has: page.getByText(folderName, { exact: true }) });
|
||||
const child = tree.getByText(childFileName, { exact: true });
|
||||
await expect(folder).toBeVisible();
|
||||
await expect(child).toBeVisible();
|
||||
await folder.click();
|
||||
await expect(child).toHaveCount(0);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openChangedFilesOverview(page);
|
||||
await expect(child).toBeVisible();
|
||||
}
|
||||
|
||||
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
|
||||
}
|
||||
|
||||
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
|
||||
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
|
||||
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(
|
||||
@@ -1820,6 +1875,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||
}
|
||||
|
||||
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(content);
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
}
|
||||
|
||||
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
}
|
||||
|
||||
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
await rightClickFirstChangedLine(page);
|
||||
const item = page.getByRole("menuitem", { name: action, exact: true });
|
||||
await expect(item).toBeEnabled();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
async function expectClipboardText(page: Page, text: string): Promise<void> {
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
|
||||
}
|
||||
|
||||
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const previousViewport = page.viewportSize()!;
|
||||
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
|
||||
await page.setViewportSize({ width: previousViewport.width, height });
|
||||
await expect(canvas).toHaveCSS(
|
||||
"height",
|
||||
`${previousHeight + height - previousViewport.height}px`,
|
||||
);
|
||||
}
|
||||
|
||||
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import {
|
||||
composerLocator,
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_RESPONSE = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].join("\n\n");
|
||||
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
|
||||
}).join("\n\n");
|
||||
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function openChatFind(page: Page) {
|
||||
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
}
|
||||
async function searchChat(page: Page, text: string) {
|
||||
await openChatFind(page);
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectHighlight(page: Page, text: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const entries = Array.from(CSS.highlights.entries()).filter(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const texts: string[] = [];
|
||||
for (const [, highlight] of entries) {
|
||||
for (const range of highlight) texts.push(range.toString());
|
||||
}
|
||||
return texts;
|
||||
}),
|
||||
)
|
||||
.toEqual([text]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return false;
|
||||
const rect = range.getBoundingClientRect();
|
||||
const row = range.startContainer.parentElement?.closest(
|
||||
'[data-testid="agent-chat-scroll"]',
|
||||
);
|
||||
const viewport = row?.getBoundingClientRect();
|
||||
return (
|
||||
!!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
async function nextMatch(page: Page) {
|
||||
await query(page).press("Enter");
|
||||
}
|
||||
async function previousMatch(page: Page) {
|
||||
await query(page).press("Shift+Enter");
|
||||
}
|
||||
async function expectFindStatus(page: Page, search: string, matches: string) {
|
||||
await query(page).fill(search);
|
||||
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
|
||||
}
|
||||
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText(matches);
|
||||
await expectHighlight(page, highlighted);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")),
|
||||
),
|
||||
)
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-",
|
||||
title: "Chat Find",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 45; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
const jumps = await trackPromptJumpRequests(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
await searchChat(page, "hello world");
|
||||
await expectHighlight(page, "hello world");
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
|
||||
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText("2 of 90");
|
||||
await expectHighlight(page, "hello world");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await query(page).fill("hello & world");
|
||||
await expectHighlight(page, "hello & world");
|
||||
await query(page).fill("a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
await query(page).fill("Prompt number 0");
|
||||
await expectHighlight(page, "Prompt number 0");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// The scope of the search is the whole chat, so the counter is the position across
|
||||
// every message that holds a hit, and Next walks off the end of one into the next.
|
||||
const ACROSS_PROMPT = "Find scope-needle in this chat";
|
||||
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
|
||||
|
||||
/** The test id of the message that holds the current match, so it can be watched crossing one. */
|
||||
async function highlightedMessage(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
}
|
||||
async function expectHighlightedMessage(page: Page, testId: string) {
|
||||
await expect.poll(() => highlightedMessage(page)).toBe(testId);
|
||||
}
|
||||
|
||||
test("counts and steps through every match in the chat, not just the selected message", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-across-",
|
||||
title: "Chat Find across messages",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
// One hit in the prompt and two in the reply: three across the chat.
|
||||
await searchChat(page, "scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await expectNextMatch(page, "3 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
|
||||
await expectNextMatch(page, "1 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("3 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-cancel-",
|
||||
title: "Chat Find cancellation",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "hidden-destination");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await query(page).fill("first cell second cell");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
gate.holdNextServerMessage("agent.timeline.search.response");
|
||||
await query(page).fill("hello world");
|
||||
await expect(status(page)).toHaveText("Searching…");
|
||||
await gate.waitForHeldServerMessage("agent.timeline.search.response");
|
||||
await closeFind(page);
|
||||
gate.releaseHeldServerMessage("agent.timeline.search.response");
|
||||
await searchChat(page, "a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows a disconnected search failure and recovers with Retry", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-retry-",
|
||||
title: "Chat Find retry",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await searchChat(page, "hello world");
|
||||
await expect(status(page)).toHaveText("Failed");
|
||||
await expect(
|
||||
page.getByText("Could not search this chat. Check the host connection and retry.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
gate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expectHighlight(page, "hello world");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
...agent,
|
||||
replyFinished: async () => {
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
// The only hit is in the first paragraph, which is no longer the row that grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.replyFinished();
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two Markdown blocks, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
await expectNextMatch(page, "2 of 2", "pair-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a chat whose newest reply is a single message far taller than the viewport,
|
||||
* behind enough older turns that the oldest one is not hydrated yet.
|
||||
*/
|
||||
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-virtualized-",
|
||||
title: "Chat Find virtualized message",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Every block row of the selected message must be mounted for Find to see it, whatever
|
||||
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
|
||||
// from the count and Next walks off to the next message instead.
|
||||
test("counts every occurrence of a virtualized message and steps between them", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatWithVirtualizedTallMessage(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
await expectFindStatus(page, "span-needle", "1 of 12");
|
||||
await expectHighlight(page, "span-needle");
|
||||
await expectNextMatch(page, "2 of 12", "span-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tail = "oversized-tail-needle";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-cap-",
|
||||
title: "Chat Find long message",
|
||||
initialPrompt: "Render a long response",
|
||||
// One paragraph, so the needle is past the render cap of the row that holds it.
|
||||
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message).toBeVisible();
|
||||
await expect(message).not.toContainText(tail);
|
||||
await searchChat(page, tail);
|
||||
await expectHighlight(page, tail);
|
||||
await closeFind(page);
|
||||
await expect(message).not.toContainText(tail);
|
||||
await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-composer-",
|
||||
title: "Chat Find from composer",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await searchFromComposerWithoutLosingDraft(page, shortcut);
|
||||
await refocusFindAndReplaceQuery(page, shortcut);
|
||||
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
|
||||
await closeFind(page);
|
||||
await expectCommandCenterOwnsFindShortcut(page, shortcut);
|
||||
await fillComposerDraft(page, "Draft after closing Find");
|
||||
await expectComposerDraft(page, "Draft after closing Find");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
test.describe("macOS", () => {
|
||||
test.use({ userAgent: MAC_USER_AGENT });
|
||||
|
||||
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Meta+f", testInfo);
|
||||
});
|
||||
|
||||
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
|
||||
test("leaves Control+f to the composer", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-mac-control-f-",
|
||||
title: "Chat Find macOS Control+f",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await fillComposerDraft(page, "Keep this draft");
|
||||
await expectComposerFocused(page);
|
||||
await recordComposerKeydown(page);
|
||||
await page.keyboard.press("Control+f");
|
||||
await expect
|
||||
.poll(() => recordedComposerFindKeydown(page))
|
||||
.toEqual([{ key: "f", defaultPrevented: false }]);
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectComposerFocused(page);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedKeydown {
|
||||
key: string;
|
||||
defaultPrevented: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Find listens on `document` in the capture phase, so the composer only ever
|
||||
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
|
||||
* on the composer the evidence of who owns the key.
|
||||
*/
|
||||
async function recordComposerKeydown(page: Page) {
|
||||
await composerLocator(page).evaluate((element) => {
|
||||
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
|
||||
store.__findKeydown = [];
|
||||
element.addEventListener("keydown", (event) => {
|
||||
store.__findKeydown.push({
|
||||
key: (event as KeyboardEvent).key,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
|
||||
const recorded = await page.evaluate(
|
||||
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
|
||||
);
|
||||
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
|
||||
}
|
||||
|
||||
async function openFindFromComposer(page: Page, shortcut: string) {
|
||||
await page.keyboard.press(shortcut);
|
||||
await expect(query(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function searchFromComposerWithoutLosingDraft(page: Page, shortcut: string) {
|
||||
await fillComposerDraft(page, "Keep this draft");
|
||||
await expectComposerFocused(page);
|
||||
await openFindFromComposer(page, shortcut);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
await query(page).fill("hello world");
|
||||
await expectHighlight(page, "hello world");
|
||||
}
|
||||
|
||||
async function refocusFindAndReplaceQuery(page: Page, shortcut: string) {
|
||||
await composerLocator(page).click();
|
||||
await expectComposerFocused(page);
|
||||
await openFindFromComposer(page, shortcut);
|
||||
await expect(query(page)).toHaveValue("hello world");
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
await page.keyboard.type("a.b");
|
||||
await expect(query(page)).toHaveValue("a.b");
|
||||
await expectHighlight(page, "a.b");
|
||||
}
|
||||
|
||||
async function expectCommandCenterOwnsFindShortcut(page: Page, shortcut: string) {
|
||||
await openCommandCenter(page);
|
||||
await page.keyboard.press(shortcut);
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible();
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await closeCommandCenter(page);
|
||||
}
|
||||
@@ -17,10 +17,14 @@ import {
|
||||
hoverChatOutlinePrompt,
|
||||
chatOutlineRail,
|
||||
movePointerOffChatOutline,
|
||||
observePromptIndexRequests,
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -41,6 +45,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const prompt = "Explain in several paragraphs.";
|
||||
await withStreamingMarkdownOutline(async (agent) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await expectChatOutlinePrompts(page, 2);
|
||||
|
||||
await expectReadingStreamedMarkdown(page, prompt);
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectActiveChatOutlinePrompt(page, 2);
|
||||
await clickChatOutlineRowEdge(page, 2);
|
||||
await expectTimelinePromptLandedBelowTop(page, prompt);
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
@@ -239,3 +268,21 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
|
||||
const promptIndex = await observePromptIndexRequests(page);
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start an agent with an outline.");
|
||||
await creation.expectPromptVisible();
|
||||
|
||||
const agentIds = await creation.agentIds();
|
||||
expect(agentIds).toHaveLength(1);
|
||||
await promptIndex.waitForRequestFor(agentIds[0]!);
|
||||
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an existing agent's only supported thinking level", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-controls-single-thinking-",
|
||||
title: "Single thinking level",
|
||||
model: "max-only-thinking-stream",
|
||||
thinkingOptionId: "max",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
|
||||
).toHaveAccessibleName("Select thinking option (Max)");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
@@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => {
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await test.step("find an agent and open its workspace tab", async () => {
|
||||
const agentPanel = await openCommandCenter(page);
|
||||
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -87,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
|
||||
const committedAt = new Date();
|
||||
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
|
||||
await page.clock.install({ time: committedAt });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await page.getByRole("button", { name: /Commits/i }).click();
|
||||
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
});
|
||||
|
||||
async function createFeatureCommit(
|
||||
repoPath: string,
|
||||
authorDate = "2020-01-15T12:00:00Z",
|
||||
): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_AUTHOR_DATE: authorDate,
|
||||
GIT_COMMITTER_DATE: authorDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { attachFileFromMenu, controlFileUploadCompletion } from "../support/helpers/composer";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -52,17 +53,29 @@ const TEST_JSON = {
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
test("selected file shows a loading attachment until upload is acknowledged", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const upload = await controlFileUploadCompletion(page);
|
||||
const workspace = await withWorkspace({ prefix: "attach-upload-pending-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
upload.hold();
|
||||
await attachFileFromMenu(page, TEST_JSON);
|
||||
await upload.waitForUpload();
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
const pending = page.getByTestId("composer-pending-file-attachment");
|
||||
await expect(pending).toContainText(TEST_JSON.name);
|
||||
await expect(pending.getByRole("progressbar")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toHaveCount(0);
|
||||
|
||||
upload.complete();
|
||||
await expect(pending).toHaveCount(0);
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText(TEST_JSON.name);
|
||||
await expectComposerEditable(page);
|
||||
});
|
||||
|
||||
test("compact Plus menu aligns attachment rows with its sheet title", async ({
|
||||
@@ -101,59 +114,27 @@ test.describe("Composer attachments", () => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
category: "attachments",
|
||||
issues: [{ title: "fix: attachment issue" }],
|
||||
prs: [{ title: "feat: attachment pull request", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
// Choose from the repository list. Newly created repositories are not
|
||||
// necessarily available in GitHub's separate text-search index yet.
|
||||
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
@@ -168,7 +149,7 @@ test.describe("Composer attachments", () => {
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
test("attaches, previews, and removes an image before dropping a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -178,38 +159,25 @@ test.describe("Composer attachments", () => {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await test.step("open the available attachment choices", async () => {
|
||||
await openAttachmentMenu(page);
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in active chat", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await test.step("remove the image and attach a dropped file", async () => {
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
});
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
@@ -238,23 +206,6 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -337,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function recordCommandRequestsByAgentId(page: Page): string[] {
|
||||
const requests: string[] = [];
|
||||
const record = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
|
||||
if (!payload.includes("draftConfig")) requests.push(payload);
|
||||
};
|
||||
page.on("websocket", (socket) => socket.on("framesent", record));
|
||||
return requests;
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
@@ -358,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
|
||||
|
||||
await page.goto("/new");
|
||||
await expectNewWorkspaceProjectSelected(page, "Choose project");
|
||||
await composerLocator(page).fill("/");
|
||||
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
|
||||
await expect(popover).not.toContainText("/clear");
|
||||
await expect(popover).not.toContainText("/exit");
|
||||
expect(commandRequestsByAgentId).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
@@ -671,7 +702,7 @@ test.describe("Composer autocomplete", () => {
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
const draftTabs = page
|
||||
.locator('[data-testid^="workspace-tab-draft"]')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Composer diff stat",
|
||||
repo: { withRemote: true },
|
||||
repo: {
|
||||
withRemote: true,
|
||||
// Exclude the local remote before the daemon starts observing this checkout.
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||
|
||||
@@ -65,6 +65,19 @@ test("blank composer lines remain present and keep their measured height", async
|
||||
const blankLines = "\n\n\n\n\n";
|
||||
const collapsedHeight = await composerHeight(page);
|
||||
|
||||
await test.step("remeasure the current draft when the window narrows", async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 1200 });
|
||||
const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8);
|
||||
await composer.fill(text);
|
||||
await expect(composer).toHaveValue(text);
|
||||
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
|
||||
const wideHeight = await composerHeight(page);
|
||||
await page.setViewportSize({ width: 480, height: 1200 });
|
||||
await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight);
|
||||
await expect(composer).toHaveValue(text);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
await test.step("grow the composer with blank lines followed by text", async () => {
|
||||
await composer.fill(`${blankLines}x`);
|
||||
await expect(composer).toHaveValue(`${blankLines}x`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
const ring = meter.locator("svg");
|
||||
const progressColor = await ring
|
||||
.locator("circle")
|
||||
.last()
|
||||
.evaluate((circle) => getComputedStyle(circle).stroke);
|
||||
const screenshot = await ring.screenshot();
|
||||
return ring.evaluate(
|
||||
async (_svg, { png, color }) => {
|
||||
const image = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${png}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(image.width, image.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, image.width, image.height);
|
||||
const [r, g, b] = color.match(/\d+/g)!.map(Number);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const i = (y * image.width + x) * 4;
|
||||
const distance =
|
||||
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
|
||||
if (distance < 40) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new Error(`No pixels painted in the progress colour ${color}`);
|
||||
}
|
||||
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
|
||||
},
|
||||
{ png: screenshot.toString("base64"), color: progressColor },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("context window meter", () => {
|
||||
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-meter-",
|
||||
title: "Context window meter e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { createCreationScenario } from "../support/helpers/creation";
|
||||
import { expect } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
|
||||
creation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm(isolation);
|
||||
await creation.submitRepeatedly("Create");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
});
|
||||
}
|
||||
|
||||
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("retrying the app's agent creation returns the same agent for every attempt", async ({
|
||||
creation,
|
||||
agentRetries,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start exactly one agent for this prompt.");
|
||||
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Name this new agent from its first prompt.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectAgentTitle("Name this new agent from its first prompt.");
|
||||
});
|
||||
|
||||
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
|
||||
creation,
|
||||
promptRetry,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
promptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Deliver this initial prompt once.");
|
||||
await promptRetry.waitForDeliveredPrompt();
|
||||
await promptRetry.disconnectAndReconnect();
|
||||
await promptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a remounted draft reconciles its original creation after reconnect", async ({
|
||||
creation,
|
||||
promptRetry,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
promptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Deliver this initial prompt once.");
|
||||
await promptRetry.waitForDeliveredPrompt();
|
||||
await creation.evictAndReturnToDraft();
|
||||
await creation.expectPromptVisible("Deliver this initial prompt once.");
|
||||
await promptRetry.disconnectAndReconnect();
|
||||
await promptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
|
||||
creation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
|
||||
await creation.expectPromptVisible();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("separate drafts can intentionally create two agents in the same workspace", async ({
|
||||
creation,
|
||||
delayedCreation,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start the first agent.");
|
||||
await creation.expectPromptVisible("Start the first agent.");
|
||||
await delayedCreation.waitForDelayedCreatedStatus();
|
||||
await creation.startAnotherDraft();
|
||||
await creation.submitPrompt("Start the second agent.");
|
||||
await creation.expectPromptVisible("Start the second agent.");
|
||||
await delayedCreation.waitForDelayedCreatedStatus();
|
||||
delayedCreation.release();
|
||||
await creation.expectAgentCount(2);
|
||||
});
|
||||
|
||||
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
|
||||
creation,
|
||||
startup,
|
||||
delayedCreation,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
delayedCreation.expectSingleWorkspaceIntent();
|
||||
delayedCreation.release();
|
||||
await startup.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ suffix: "", prepare: async () => {} },
|
||||
{
|
||||
suffix: " after remount",
|
||||
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.evictAndReturnToDraft(),
|
||||
},
|
||||
]) {
|
||||
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { metroTest, expect } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { withProjectOwnership } from "../support/helpers/project-ownership";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation";
|
||||
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
|
||||
|
||||
for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
let daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
const test = metroTest.extend<{
|
||||
client: DaemonClient;
|
||||
gate: Awaited<ReturnType<typeof holdLegacyAgentCreation>>;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
}>({
|
||||
gate: async ({ page }, provide) => {
|
||||
const gate = await holdLegacyAgentCreation(page, daemon.port);
|
||||
await provide(gate);
|
||||
gate.release();
|
||||
},
|
||||
client: async ({}, provide) => {
|
||||
const client = withProjectOwnership(
|
||||
await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "legacy-creation",
|
||||
port: daemon.port,
|
||||
}),
|
||||
);
|
||||
await provide(client);
|
||||
await client.close();
|
||||
},
|
||||
repo: async ({}, provide) => {
|
||||
const repo = await createTempGitRepo("legacy-creation-");
|
||||
await provide(repo);
|
||||
await repo.cleanup();
|
||||
},
|
||||
project: async ({}, provide) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port });
|
||||
await provide(project);
|
||||
await project.cleanup();
|
||||
},
|
||||
});
|
||||
test.describe(`published daemon ${version}`, () => {
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(120_000);
|
||||
daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, {
|
||||
publishedVersion: version,
|
||||
});
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await daemon?.close();
|
||||
});
|
||||
|
||||
for (const kind of ["workspace", "agent"] as const) {
|
||||
test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({
|
||||
repo,
|
||||
client,
|
||||
}) => {
|
||||
const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`;
|
||||
const features = client.getLastServerInfoMessage()?.features;
|
||||
expect(features?.creationLifecycle).not.toBe(true);
|
||||
expect(features?.workspaceRequestReceipts).not.toBe(true);
|
||||
expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0");
|
||||
|
||||
const create = await prepareCreation(client, kind, repo.path, prompt);
|
||||
const [agent, duplicate] = await Promise.all([create(), create()]);
|
||||
expect(duplicate.id).toBe(agent.id);
|
||||
expect(agent.title).toBe(prompt);
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
const agents = await client.fetchAgents();
|
||||
expect(agents.entries).toHaveLength(1);
|
||||
expect(agents.entries[0]!.agent.title).toBe(prompt);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id);
|
||||
expect(
|
||||
timeline.entries.filter(
|
||||
({ item }) => item.type === "user_message" && item.text === prompt,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
|
||||
page,
|
||||
project,
|
||||
client,
|
||||
gate,
|
||||
}) => {
|
||||
await openOldHost(page, project.workspaceId);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Create");
|
||||
await gate.waitForRequest();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(0);
|
||||
const workspaces = (await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
await expectPromptOnce(client, first.id, prompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
|
||||
).toHaveText(prompt);
|
||||
|
||||
await createAgentTabFromMenu(page);
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
)!.agent;
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
await client.waitForFinish(second.id, 20_000);
|
||||
await expectPromptOnce(client, second.id, secondPrompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
|
||||
).toHaveText(secondPrompt);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ visible: true })
|
||||
.filter({ hasText: secondPrompt }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
|
||||
});
|
||||
|
||||
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId);
|
||||
expect(
|
||||
timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function openOldHost(page: Page, workspaceId: string) {
|
||||
const host = buildSeededHost({
|
||||
serverId: daemon.serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
|
||||
}
|
||||
|
||||
async function prepareCreation(
|
||||
client: DaemonClient,
|
||||
kind: "workspace" | "agent",
|
||||
cwd: string,
|
||||
prompt: string,
|
||||
) {
|
||||
const input = { provider: "mock", cwd, initialPrompt: prompt };
|
||||
if (kind === "workspace")
|
||||
return async () => {
|
||||
const result = await client.createWorkspace({
|
||||
idempotencyKey: "workspace-intent",
|
||||
source: { kind: "worktree", cwd, worktreeSlug: "created-once" },
|
||||
agent: input,
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.workspace?.workspaceDirectory).not.toBe(cwd);
|
||||
return result.agent!;
|
||||
};
|
||||
const workspace = await client.createWorkspace({
|
||||
idempotencyKey: "empty-workspace",
|
||||
source: { kind: "directory", path: cwd },
|
||||
});
|
||||
return () =>
|
||||
client.createAgent({
|
||||
...input,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
idempotencyKey: "agent-intent",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
send();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import {
|
||||
clickNewChat,
|
||||
gotoWorkspace,
|
||||
pressDirectNewTabShortcut,
|
||||
} from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const focusedPaneChild = page
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
|
||||
const launcher = visibleNewTabPanel(page);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import {
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
@@ -93,132 +93,221 @@ async function dragChipTo(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
function collectConsoleErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
e2eWorkerClient,
|
||||
}, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
|
||||
const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
|
||||
let agentId = "";
|
||||
|
||||
try {
|
||||
await test.step("empty workspace seeds a draft in the main pane", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
await test.step("choose Agent from the empty workspace launcher", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
});
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
const agent = await e2eWorkerClient.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Explorer Placement Agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
// Keep the agent streaming through the drag so pane contents are live,
|
||||
// matching the reporter's "spinners were still going" environment.
|
||||
initialPrompt: "stream please",
|
||||
});
|
||||
agentId = agent.id;
|
||||
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(
|
||||
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
await closeSeededDraftInMainPane(page);
|
||||
await expect(mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await testInfo.attach("before-drag", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("split the agent out of the main pane, leaving it empty", async () => {
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
const mainBox = await paneBoxContainingChip(page, chip);
|
||||
// Land inside the left 15% edge band of the main pane content.
|
||||
await dragChipTo(page, chip, {
|
||||
x: mainBox.x + mainBox.width * 0.06,
|
||||
y: mainBox.y + mainBox.height * 0.6,
|
||||
});
|
||||
await testInfo.attach("after-split", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
// JS main thread alive?
|
||||
const pong = await page.evaluate("1 + 1");
|
||||
expect(pong).toBe(2);
|
||||
|
||||
// Pointer interaction alive? Selecting the agent chip must still work.
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await testInfo.attach("after-center-drop", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Explorer Placement Agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
// Keep the agent streaming through the drag so pane contents are live,
|
||||
// matching the reporter's "spinners were still going" environment.
|
||||
initialPrompt: "stream please",
|
||||
});
|
||||
agentId = agent.id;
|
||||
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(
|
||||
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
// The explorer toggle must still respond.
|
||||
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
|
||||
await testInfo.attach("after-interactions", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
await closeSeededDraftInMainPane(page);
|
||||
await expect(
|
||||
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
|
||||
).toHaveCount(1);
|
||||
await testInfo.attach("before-drag", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("split the agent out of the main pane, leaving it empty", async () => {
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
const mainBox = await paneBoxContainingChip(page, chip);
|
||||
// Land inside the left 15% edge band of the main pane content.
|
||||
await dragChipTo(page, chip, {
|
||||
x: mainBox.x + mainBox.width * 0.06,
|
||||
y: mainBox.y + mainBox.height * 0.6,
|
||||
});
|
||||
await testInfo.attach("after-split", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
// JS main thread alive?
|
||||
const pong = await page.evaluate("1 + 1");
|
||||
expect(pong).toBe(2);
|
||||
|
||||
// Pointer interaction alive? Selecting the agent chip must still work.
|
||||
const chip = agentTabChip(page, agentId).first();
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await testInfo.attach("after-center-drop", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
// The explorer toggle must still respond.
|
||||
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
|
||||
await testInfo.attach("after-interactions", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
await testInfo.attach("console-errors", {
|
||||
body: JSON.stringify(consoleErrors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
await testInfo.attach("console-errors", {
|
||||
body: JSON.stringify(consoleErrors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function closeOnlyDraft(page: Page): Promise<void> {
|
||||
await draftTabChip(page).hover();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
}
|
||||
|
||||
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, draftTabChip(page), {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectNewLauncher(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(draftTabChip(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
|
||||
await expectNewLauncher(page);
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-panel")
|
||||
.getByRole("button", { name: "Agent", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
// Explorer cannot replace the ordinary workspace canvas, including on restore.
|
||||
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await moveOnlyDraftIntoRightSplit(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
});
|
||||
|
||||
test("visible Explorer does not replace the last ordinary workspace pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
|
||||
await openAgentDraftFromLauncher(page);
|
||||
await moveOnlyDraftIntoRightSplit(page);
|
||||
await closeOnlyDraft(page);
|
||||
await expectNewLauncher(page);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
});
|
||||
|
||||
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const otherWorkspace = await withWorkspace({ prefix: "uncorrupted-explorer-" });
|
||||
await gotoWorkspace(page, otherWorkspace.workspaceId);
|
||||
await openAgentDraftFromLauncher(page);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expectNewLauncher(page);
|
||||
await seedCorruptedWorkspaceLayout(page, workspace.workspaceId);
|
||||
await page.reload();
|
||||
await expectNewLauncher(page);
|
||||
await gotoWorkspace(page, otherWorkspace.workspaceId);
|
||||
await expect(draftTabChip(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -111,21 +111,6 @@ test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const row = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByTestId(/^file-explorer-row-\d+$/)
|
||||
.first();
|
||||
const name = row.getByTestId(/-name$/);
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
await expect(name).toHaveCSS("opacity", "0.76");
|
||||
await row.hover();
|
||||
await expect(name).toHaveCSS("opacity", "1");
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user